Compare commits
1 Commits
renovate/g
...
cc4da513e7
| Author | SHA1 | Date | |
|---|---|---|---|
| cc4da513e7 |
@@ -11,5 +11,5 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
- run: docker build . -t web-app-template-test
|
- run: docker build . -t me-fit-test
|
||||||
- run: docker rmi web-app-template-test
|
- run: docker rmi me-fit-test
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ jobs:
|
|||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 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/web-app-template:latest -t git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
|
- run: docker build . -t git.wundenbergs.de/x/me-fit:latest -t git.wundenbergs.de/x/me-fit:$GITHUB_SHA
|
||||||
- run: docker push git.wundenbergs.de/x/web-app-template:latest
|
- run: docker push git.wundenbergs.de/x/me-fit:latest
|
||||||
- run: docker push git.wundenbergs.de/x/web-app-template:$GITHUB_SHA
|
- run: docker push 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
|
- run: docker rmi git.wundenbergs.de/x/me-fit:latest git.wundenbergs.de/x/me-fit:$GITHUB_SHA
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ dir: mocks/
|
|||||||
outpkg: mocks
|
outpkg: mocks
|
||||||
issue-845-fix: True
|
issue-845-fix: True
|
||||||
packages:
|
packages:
|
||||||
web-app-template/service:
|
me-fit/service:
|
||||||
interfaces:
|
interfaces:
|
||||||
Random:
|
Random:
|
||||||
Clock:
|
Clock:
|
||||||
Mail:
|
Mail:
|
||||||
web-app-template/db:
|
me-fit/db:
|
||||||
interfaces:
|
interfaces:
|
||||||
Auth:
|
Auth:
|
||||||
|
|||||||
22
Dockerfile
22
Dockerfile
@@ -1,6 +1,6 @@
|
|||||||
FROM golang:1.24.5@sha256:ef5b4be1f94b36c90385abd9b6b4f201723ae28e71acacb76d00687333c17282 AS builder_go
|
FROM golang:1.23.4@sha256:70031844b8c225351d0bb63e2c383f80db85d92ba894e3da7e13bcf80efa9a37 AS builder_go
|
||||||
WORKDIR /web-app-template
|
WORKDIR /me-fit
|
||||||
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
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
|
RUN go install github.com/vektra/mockery/v2@latest
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
@@ -10,23 +10,23 @@ RUN templ generate
|
|||||||
RUN mockery --log-level warn
|
RUN mockery --log-level warn
|
||||||
RUN go test ./...
|
RUN go test ./...
|
||||||
RUN golangci-lint run ./...
|
RUN golangci-lint run ./...
|
||||||
RUN go build -o /web-app-template/web-app-template .
|
RUN go build -o /me-fit/me-fit .
|
||||||
|
|
||||||
|
|
||||||
FROM node:22.18.0@sha256:3218f0d1b9e4b63def322e9ae362d581fbeac1ef21b51fc502ef91386667ce92 AS builder_node
|
FROM node:22.12.0@sha256:35a5dd72bcac4bce43266408b58a02be6ff0b6098ffa6f5435aeea980a8951d7 AS builder_node
|
||||||
WORKDIR /web-app-template
|
WORKDIR /me-fit
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm clean-install
|
RUN npm clean-install
|
||||||
COPY . ./
|
COPY . ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM debian:12.11@sha256:b6507e340c43553136f5078284c8c68d86ec8262b1724dde73c325e8d3dcdeba
|
FROM debian:12.8@sha256:17122fe3d66916e55c0cbd5bbf54bb3f87b3582f4d86a755a0fd3498d360f91b
|
||||||
WORKDIR /web-app-template
|
WORKDIR /me-fit
|
||||||
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 migration ./migration
|
COPY migration ./migration
|
||||||
COPY --from=builder_go /web-app-template/web-app-template ./web-app-template
|
COPY --from=builder_go /me-fit/me-fit ./me-fit
|
||||||
COPY --from=builder_node /web-app-template/static ./static
|
COPY --from=builder_node /me-fit/static ./static
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/web-app-template/web-app-template"]
|
ENTRYPOINT ["/me-fit/me-fit"]
|
||||||
|
|
||||||
|
|||||||
92
Readme.md
92
Readme.md
@@ -1,98 +1,44 @@
|
|||||||
|
|
||||||
# Web-App-Template
|
# stackFAST
|
||||||
|
|
||||||
A basic template with authentication to easily host on a VPC.
|
Your (almost) independent tech stack to host on a VPC.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
This template includes everything essential to build an app. It includes the following features:
|
stackFAST includes everything you need to build your App. Focus yourself on developing your idea, instead of "wasting" time on things like setting up auth and observability. This blueprint tries to include as much as possible, but still keep it simple.
|
||||||
|
|
||||||
- Authentication: Users can login, logout, register and reset their password. (for increased security TOTP is planned aswell.)
|
The blueprint contains the following features:
|
||||||
- Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone.
|
- Authentication: Users can login, logout, register and reset their password. For increased security TOTP is available aswell.
|
||||||
|
- Observability: The stack contains an Grafana+Prometheus instance for basic monitoring. You are able to add alerts and get notified on your phone. For web analytics umami is included, which is an lighweight self hosted alternative to google analytics.
|
||||||
- Mail: You are able to send mail with SMTP. You still need an external Mail Server, but a guide on how to set that up with a custom domain is included.
|
- 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.
|
- 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
|
||||||
- Stack: Tailwindcss + HTMX + GO Backend with templ and sqlite
|
- Actual Stack: SSG SvelteKit + Tailwindcss + DaisyUI + GO Backend for easy and fast feature development
|
||||||
|
|
||||||
|
|
||||||
## 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, If only security experts are allowed to write software, what does that result in? I'm going to explain my criterions and afterwards take a decision.
|
Authentication is a broad topic. Many people think you should not consider implementing authentication yourself. On the other hand, experts at OWASP don't recommend this in their cheat sheet on that topic. I'm going to explain my criterions and afterwards take a decision.
|
||||||
|
|
||||||
There are a few restrictions I would like to contain:
|
There are a few restrictions I would like to contain:
|
||||||
- I want this template do as much as as possible without relying on external services. This way the setup cost and dependencies can be minimized.
|
- 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.
|
||||||
- It should still be possible to run on a small VPC (2vcpu, 2GB).
|
- I think most cloud services are overpriced. I want to provide an alternative approach with self holsting. But I don't like the idea to spin up 30 services for a small app with 0 users. It should still be possible to run on a small VPC (2vcpu, 2GB).
|
||||||
- It should be as secure as possible
|
- It should be as secure as possible
|
||||||
|
|
||||||
I determined 4 options:
|
As of 2024 there are 4 options:
|
||||||
1. Implement the authentication myself
|
- 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.
|
||||||
2. Using OAuth2 with Keycloak
|
- 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.
|
||||||
3. Using OAuth2 with Google and Apple
|
- 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.
|
||||||
4. Firebase, Clerk, etc.
|
- Firebase, Clerk, etc.: Users have to sign up again AND blueprint users have to setup another project.
|
||||||
|
|
||||||
|
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 emails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails.
|
For Email verification, etc. a mail server is needed, that can send a whole lot of mails. Aditionally, a mail account is needed for incoming mails. I thought about self hosting, but unfortunatly this is a hastle to maintain. Not only you have to setup a mail server, which is not as easy as it sounds, you also have to "register" your mail server for diffrent providers. Otherwise you are not able to send and receive emails. Thus, the first external service is needed.
|
||||||
|
|
||||||
In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails.
|
In order to not vendor lock in, I decided to use an SMTP relay in favor of a vendor specific API. You are free to choose a transactional mail provider. I chose brevo.com. They have a generous free tier of 300 mails per day. You can either upgrade to a monthly plan 10$ for 20k mails or buy credits for 30$ for 5k mails. Most provider provide 100 mails / day for free.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
|||||||
29
go.mod
29
go.mod
@@ -1,19 +1,17 @@
|
|||||||
module web-app-template
|
module me-fit
|
||||||
|
|
||||||
go 1.23.0
|
go 1.22.5
|
||||||
|
|
||||||
toolchain go1.24.5
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/a-h/templ v0.3.924
|
github.com/a-h/templ v0.2.793
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.3
|
github.com/golang-migrate/migrate/v4 v4.18.1
|
||||||
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.30
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
github.com/prometheus/client_golang v1.23.0
|
github.com/prometheus/client_golang v1.20.5
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/crypto v0.41.0
|
golang.org/x/crypto v0.31.0
|
||||||
golang.org/x/net v0.43.0
|
golang.org/x/net v0.33.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -22,14 +20,15 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.1 // 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/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/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/prometheus/common v0.65.0 // indirect
|
github.com/prometheus/common v0.55.0 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // 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.35.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
54
go.sum
54
go.sum
@@ -1,15 +1,15 @@
|
|||||||
github.com/a-h/templ v0.3.924 h1:t5gZqTneXqvehpNZsgtnlOscnBboNh9aASBH2MgV/0k=
|
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
|
||||||
github.com/a-h/templ v0.3.924/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334=
|
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 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.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
|
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
|
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
@@ -19,8 +19,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
|
|||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 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.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -29,20 +29,20 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
|||||||
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.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY=
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.30/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.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
@@ -51,16 +51,14 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/handler/middleware"
|
"me-fit/handler/middleware"
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
"web-app-template/service"
|
"me-fit/service"
|
||||||
"web-app-template/template/auth"
|
"me-fit/template/auth"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
"web-app-template/utils"
|
"me-fit/utils"
|
||||||
|
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -45,12 +45,12 @@ func (handler AuthImpl) Handle(router *http.ServeMux) {
|
|||||||
router.Handle("/auth/delete-account", handler.handleDeleteAccountPage())
|
router.Handle("/auth/delete-account", handler.handleDeleteAccountPage())
|
||||||
router.Handle("/api/auth/delete-account", handler.handleDeleteAccountComp())
|
router.Handle("/api/auth/delete-account", handler.handleDeleteAccountComp())
|
||||||
|
|
||||||
router.Handle("GET /auth/change-password", handler.handleChangePasswordPage())
|
router.Handle("/auth/change-password", handler.handleChangePasswordPage())
|
||||||
router.Handle("POST /api/auth/change-password", handler.handleChangePasswordComp())
|
router.Handle("/api/auth/change-password", handler.handleChangePasswordComp())
|
||||||
|
|
||||||
router.Handle("GET /auth/forgot-password", handler.handleForgotPasswordPage())
|
router.Handle("/auth/forgot-password", handler.handleForgotPasswordPage())
|
||||||
router.Handle("POST /api/auth/forgot-password", handler.handleForgotPasswordComp())
|
router.Handle("/api/auth/forgot-password", handler.handleForgotPasswordComp())
|
||||||
router.Handle("POST /api/auth/forgot-password-actual", handler.handleForgotPasswordResponseComp())
|
router.Handle("/api/auth/forgot-password-actual", handler.handleForgotPasswordResponseComp())
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -307,7 +307,7 @@ func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
|
|||||||
session := middleware.GetSession(r)
|
session := middleware.GetSession(r)
|
||||||
user := middleware.GetUser(r)
|
user := middleware.GetUser(r)
|
||||||
if session == nil || user == nil {
|
if session == nil || user == nil {
|
||||||
utils.TriggerToast(w, r, "error", "Unathorized", http.StatusUnauthorized)
|
utils.DoRedirect(w, r, "/auth/signin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ func (handler AuthImpl) handleChangePasswordComp() http.HandlerFunc {
|
|||||||
|
|
||||||
err := handler.service.ChangePassword(user, session.Id, currPass, newPass)
|
err := handler.service.ChangePassword(user, session.Id, currPass, newPass)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.TriggerToast(w, r, "error", "Password not correct", http.StatusBadRequest)
|
utils.TriggerToast(w, r, "error", "Password not correct", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,13 +355,14 @@ func (handler AuthImpl) handleForgotPasswordComp() http.HandlerFunc {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
utils.TriggerToast(w, r, "error", "Internal Server Error", http.StatusInternalServerError)
|
||||||
} else {
|
} else {
|
||||||
utils.TriggerToast(w, r, "info", "If the address exists, an email has been sent.", http.StatusOK)
|
utils.TriggerToast(w, r, "info", "If the email exists, an email has been sent", http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
|
func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL"))
|
pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Could not get current URL: %v", err)
|
log.Error("Could not get current URL: %v", err)
|
||||||
@@ -374,7 +375,7 @@ func (handler AuthImpl) handleForgotPasswordResponseComp() http.HandlerFunc {
|
|||||||
|
|
||||||
err = handler.service.ForgotPassword(token, newPass)
|
err = handler.service.ForgotPassword(token, newPass)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.TriggerToast(w, r, "error", err.Error(), http.StatusBadRequest)
|
utils.TriggerToast(w, r, "error", err.Error(), http.StatusInternalServerError)
|
||||||
} else {
|
} else {
|
||||||
utils.TriggerToast(w, r, "success", "Password changed", http.StatusOK)
|
utils.TriggerToast(w, r, "success", "Password changed", http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/handler/middleware"
|
"me-fit/handler/middleware"
|
||||||
"web-app-template/service"
|
"me-fit/service"
|
||||||
"web-app-template/template"
|
"me-fit/template"
|
||||||
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -36,15 +36,13 @@ func (handler IndexImpl) handleIndexAnd404() http.HandlerFunc {
|
|||||||
|
|
||||||
var comp templ.Component
|
var comp templ.Component
|
||||||
|
|
||||||
var status int
|
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
comp = template.NotFound()
|
comp = template.NotFound()
|
||||||
status = http.StatusNotFound
|
w.WriteHeader(http.StatusNotFound)
|
||||||
} else {
|
} else {
|
||||||
comp = template.Index()
|
comp = template.Index()
|
||||||
status = http.StatusOK
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handler.render.RenderLayoutWithStatus(r, w, comp, user, status)
|
handler.render.RenderLayout(r, w, comp, user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"web-app-template/service"
|
"me-fit/service"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextKey string
|
type ContextKey string
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
"web-app-template/service"
|
"me-fit/service"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
"web-app-template/utils"
|
"me-fit/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type csrfResponseWriter struct {
|
type csrfResponseWriter struct {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"web-app-template/log"
|
"me-fit/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"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler {
|
func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler {
|
||||||
@@ -15,14 +15,14 @@ func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Han
|
|||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE")
|
||||||
w.Header().Set("Content-Security-Policy",
|
w.Header().Set("Content-Security-Policy",
|
||||||
"default-src 'none'; "+
|
"default-src 'none'; "+
|
||||||
"script-src 'self'; "+
|
"script-src 'self' https://umami.me-fit.eu; "+
|
||||||
"connect-src 'self'; "+
|
"connect-src 'self' https://umami.me-fit.eu; "+
|
||||||
"img-src 'self'; "+
|
"img-src 'self'; "+
|
||||||
"style-src 'self'; "+
|
"style-src 'self'; "+
|
||||||
"form-action 'self'; "+
|
"form-action 'self'; "+
|
||||||
"frame-ancestors 'none'; ",
|
"frame-ancestors 'none'; ",
|
||||||
)
|
)
|
||||||
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
w.Header().Set("Cross-Origin-Resource-Policy", "same-site") // same-site, as same origin prohibits umami
|
||||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
|
w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp")
|
||||||
w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()")
|
w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()")
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
"web-app-template/template"
|
"me-fit/template"
|
||||||
"web-app-template/template/auth"
|
"me-fit/template/auth"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -12,10 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Render struct {
|
type Render struct {
|
||||||
|
settings *types.Settings
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRender() *Render {
|
func NewRender(settings *types.Settings) *Render {
|
||||||
return &Render{}
|
return &Render{
|
||||||
|
settings: settings,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (render *Render) RenderWithStatus(r *http.Request, w http.ResponseWriter, comp templ.Component, status int) {
|
func (render *Render) RenderWithStatus(r *http.Request, w http.ResponseWriter, comp templ.Component, status int) {
|
||||||
@@ -38,7 +41,7 @@ func (render *Render) RenderLayout(r *http.Request, w http.ResponseWriter, slot
|
|||||||
|
|
||||||
func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User, status int) {
|
func (render *Render) RenderLayoutWithStatus(r *http.Request, w http.ResponseWriter, slot templ.Component, user *types.User, status int) {
|
||||||
userComp := render.getUserComp(user)
|
userComp := render.getUserComp(user)
|
||||||
layout := template.Layout(slot, userComp)
|
layout := template.Layout(slot, userComp, render.settings.Environment)
|
||||||
|
|
||||||
render.RenderWithStatus(r, w, layout, status)
|
render.RenderWithStatus(r, w, layout, status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/handler/middleware"
|
"me-fit/handler/middleware"
|
||||||
"web-app-template/service"
|
"me-fit/service"
|
||||||
"web-app-template/template/workout"
|
"me-fit/template/workout"
|
||||||
"web-app-template/utils"
|
"me-fit/utils"
|
||||||
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|||||||
18
input.css
18
input.css
@@ -1,18 +0,0 @@
|
|||||||
@import 'tailwindcss';
|
|
||||||
|
|
||||||
@source './static/**/*.js';
|
|
||||||
@source './template/**/*.templ';
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--animate-fade: fadeOut 0.25s ease-in;
|
|
||||||
|
|
||||||
@keyframes fadeOut {
|
|
||||||
0% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
14
main.go
14
main.go
@@ -1,12 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/db"
|
"me-fit/db"
|
||||||
"web-app-template/handler"
|
"me-fit/handler"
|
||||||
"web-app-template/handler/middleware"
|
"me-fit/handler/middleware"
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
"web-app-template/service"
|
"me-fit/service"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -113,7 +113,7 @@ func createHandler(d *sql.DB, serverSettings *types.Settings) http.Handler {
|
|||||||
authService := service.NewAuthImpl(authDb, randomService, clockService, mailService, serverSettings)
|
authService := service.NewAuthImpl(authDb, randomService, clockService, mailService, serverSettings)
|
||||||
workoutService := service.NewWorkoutImpl(workoutDb, randomService, clockService, mailService, serverSettings)
|
workoutService := service.NewWorkoutImpl(workoutDb, randomService, clockService, mailService, serverSettings)
|
||||||
|
|
||||||
render := handler.NewRender()
|
render := handler.NewRender(serverSettings)
|
||||||
indexHandler := handler.NewIndex(authService, render)
|
indexHandler := handler.NewIndex(authService, render)
|
||||||
authHandler := handler.NewAuth(authService, render)
|
authHandler := handler.NewAuth(authService, render)
|
||||||
workoutHandler := handler.NewWorkout(workoutService, authService, render)
|
workoutHandler := handler.NewWorkout(workoutService, authService, render)
|
||||||
|
|||||||
491
main_test.go
491
main_test.go
@@ -11,8 +11,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"web-app-template/service"
|
"me-fit/service"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -82,15 +82,15 @@ func TestIntegrationSecurityHeader(t *testing.T) {
|
|||||||
|
|
||||||
value = resp.Header.Get("Content-Security-Policy")
|
value = resp.Header.Get("Content-Security-Policy")
|
||||||
assert.Equal(t, "default-src 'none'; "+
|
assert.Equal(t, "default-src 'none'; "+
|
||||||
"script-src 'self'; "+
|
"script-src 'self' https://umami.me-fit.eu; "+
|
||||||
"connect-src 'self'; "+
|
"connect-src 'self' https://umami.me-fit.eu; "+
|
||||||
"img-src 'self'; "+
|
"img-src 'self'; "+
|
||||||
"style-src 'self'; "+
|
"style-src 'self'; "+
|
||||||
"form-action 'self'; "+
|
"form-action 'self'; "+
|
||||||
"frame-ancestors 'none';", value)
|
"frame-ancestors 'none';", value)
|
||||||
|
|
||||||
value = resp.Header.Get("Cross-Origin-Resource-Policy")
|
value = resp.Header.Get("Cross-Origin-Resource-Policy")
|
||||||
assert.Equal(t, "same-origin", value)
|
assert.Equal(t, "same-site", value)
|
||||||
|
|
||||||
value = resp.Header.Get("Cross-Origin-Opener-Policy")
|
value = resp.Header.Get("Cross-Origin-Opener-Policy")
|
||||||
assert.Equal(t, "same-origin", value)
|
assert.Equal(t, "same-origin", value)
|
||||||
@@ -333,7 +333,7 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
resp, err = httpClient.Do(req)
|
resp, err = httpClient.Do(req)
|
||||||
timeEnd := time.Now()
|
timeEnd := time.Now()
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
if timeEnd.Sub(timeStart) > 260*time.Millisecond || timeEnd.Sub(timeStart) <= 250*time.Millisecond {
|
if timeEnd.Sub(timeStart) > 253*time.Millisecond || timeEnd.Sub(timeStart) <= 250*time.Millisecond {
|
||||||
t.Fail()
|
t.Fail()
|
||||||
t.Logf("Time did not match: %v", timeEnd.Sub(timeStart))
|
t.Logf("Time did not match: %v", timeEnd.Sub(timeStart))
|
||||||
}
|
}
|
||||||
@@ -367,7 +367,7 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
resp, err = httpClient.Do(req)
|
resp, err = httpClient.Do(req)
|
||||||
timeEnd = time.Now()
|
timeEnd = time.Now()
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
if timeEnd.Sub(timeStart) > 260*time.Millisecond || timeEnd.Sub(timeStart) <= 250*time.Millisecond {
|
if timeEnd.Sub(timeStart) > 253*time.Millisecond || timeEnd.Sub(timeStart) <= 250*time.Millisecond {
|
||||||
t.Fail()
|
t.Fail()
|
||||||
t.Logf("Time did not match: %v", timeEnd.Sub(timeStart))
|
t.Logf("Time did not match: %v", timeEnd.Sub(timeStart))
|
||||||
}
|
}
|
||||||
@@ -401,7 +401,7 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
resp, err = httpClient.Do(req)
|
resp, err = httpClient.Do(req)
|
||||||
timeEnd = time.Now()
|
timeEnd = time.Now()
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
if timeEnd.Sub(timeStart) > 260*time.Millisecond || timeEnd.Sub(timeStart) <= 250*time.Millisecond {
|
if timeEnd.Sub(timeStart) > 253*time.Millisecond || timeEnd.Sub(timeStart) <= 250*time.Millisecond {
|
||||||
t.Fail()
|
t.Fail()
|
||||||
t.Logf("Time did not match: %v", timeEnd.Sub(timeStart))
|
t.Logf("Time did not match: %v", timeEnd.Sub(timeStart))
|
||||||
}
|
}
|
||||||
@@ -571,7 +571,7 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
timeEnd := time.Now()
|
timeEnd := time.Now()
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
timeTaken := timeEnd.Sub(timeStart)
|
timeTaken := timeEnd.Sub(timeStart)
|
||||||
assert.LessOrEqual(t, timeTaken, 260*time.Millisecond)
|
assert.LessOrEqual(t, timeTaken, 253*time.Millisecond)
|
||||||
assert.GreaterOrEqual(t, timeTaken, 250*time.Millisecond)
|
assert.GreaterOrEqual(t, timeTaken, 250*time.Millisecond)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -608,7 +608,7 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
timeEnd := time.Now()
|
timeEnd := time.Now()
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
timeTaken := timeEnd.Sub(timeStart)
|
timeTaken := timeEnd.Sub(timeStart)
|
||||||
assert.LessOrEqual(t, timeTaken, 260*time.Millisecond)
|
assert.LessOrEqual(t, timeTaken, 253*time.Millisecond)
|
||||||
assert.GreaterOrEqual(t, timeTaken, 250*time.Millisecond)
|
assert.GreaterOrEqual(t, timeTaken, 250*time.Millisecond)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -664,7 +664,6 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
INSERT INTO token (token, user_id, type, created_at, expires_at)
|
INSERT INTO token (token, user_id, type, created_at, expires_at)
|
||||||
VALUES (?, ?, ?, datetime("now", "-16 minute"), datetime("now", "-1 minute"))`, token, userId, types.TokenTypeEmailVerify)
|
VALUES (?, ?, ?, datetime("now", "-16 minute"), datetime("now", "-1 minute"))`, token, userId, types.TokenTypeEmailVerify)
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/verify-email?token="+token, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/verify-email?token="+token, nil)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
@@ -693,7 +692,6 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
_, err = db.Exec(`
|
_, err = db.Exec(`
|
||||||
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
|
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
|
||||||
VALUES (?, ?, "", ?, datetime("now"), datetime("now", "+15 minute"))`, token, userId, types.TokenTypeEmailVerify)
|
VALUES (?, ?, "", ?, datetime("now"), datetime("now", "+15 minute"))`, token, userId, types.TokenTypeEmailVerify)
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/verify-email?token="+token, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/verify-email?token="+token, nil)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
@@ -933,187 +931,7 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
assert.Equal(t, 0, rows)
|
assert.Equal(t, 0, rows)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ChangePassword", func(t *testing.T) {
|
t.Run("ChangePassword", func(t *testing.T) {
|
||||||
t.Run(`should redirect to "/" if not signed in`, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/change-password", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
|
||||||
assert.Equal(t, "/auth/signin", resp.Header.Get("Location"))
|
|
||||||
})
|
|
||||||
t.Run(`should throw unautohorized if not signed in`, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/signin", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
html, err := html.Parse(resp.Body)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
anonymousCsrfToken := findCsrfToken(html)
|
|
||||||
assert.NotEqual(t, "", anonymousCsrfToken)
|
|
||||||
anonymousSessionId := findCookie(resp, "id").Value
|
|
||||||
assert.NotEqual(t, "", anonymousSessionId)
|
|
||||||
|
|
||||||
formData := url.Values{
|
|
||||||
"current-password": {"password"},
|
|
||||||
"new-password": {"MyNewSecurePassword1!"},
|
|
||||||
"csrf-token": {anonymousCsrfToken},
|
|
||||||
}
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/change-password", strings.NewReader(formData.Encode()))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Cookie", "id="+anonymousSessionId)
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
resp, err = httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
||||||
})
|
|
||||||
t.Run(`should fail if csrf token is invalid`, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
userId := uuid.New()
|
|
||||||
|
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
|
||||||
_, err := db.Exec(`
|
|
||||||
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
|
|
||||||
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
|
|
||||||
|
|
||||||
sessionId := "session-id"
|
|
||||||
assert.Nil(t, err)
|
|
||||||
_, err = db.Exec(`
|
|
||||||
INSERT INTO session (session_id, user_id, created_at, expires_at)
|
|
||||||
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
formData := url.Values{
|
|
||||||
"current-password": {"password"},
|
|
||||||
"new-password": {"MyNewSecurePassword1!"},
|
|
||||||
"csrf-token": {"invalid-csrf-token"},
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/change-password", strings.NewReader(formData.Encode()))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Cookie", "id="+sessionId)
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
|
|
||||||
var rows int
|
|
||||||
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, rows)
|
|
||||||
})
|
|
||||||
t.Run("should fail if current password does not match", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
userId := uuid.New()
|
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
|
||||||
|
|
||||||
_, err := db.Exec(`
|
|
||||||
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
|
|
||||||
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
|
|
||||||
|
|
||||||
sessionId := "session-id"
|
|
||||||
assert.Nil(t, err)
|
|
||||||
_, err = db.Exec(`
|
|
||||||
INSERT INTO session (session_id, user_id, created_at, expires_at)
|
|
||||||
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/change-password", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Cookie", "id="+sessionId)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
html, err := html.Parse(resp.Body)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
csrfToken := findCsrfToken(html)
|
|
||||||
assert.NotEqual(t, "", csrfToken)
|
|
||||||
|
|
||||||
formData := url.Values{
|
|
||||||
"current-password": {"wrong-password"},
|
|
||||||
"new-password": {"MyNewSecurePassword1!"},
|
|
||||||
"csrf-token": {csrfToken},
|
|
||||||
}
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/change-password", strings.NewReader(formData.Encode()))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Cookie", "id="+sessionId)
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
resp, err = httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
|
|
||||||
var rows int
|
|
||||||
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, rows)
|
|
||||||
})
|
|
||||||
t.Run("should fail if new password is insecure", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
userId := uuid.New()
|
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
|
||||||
|
|
||||||
_, err := db.Exec(`
|
|
||||||
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
|
|
||||||
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
|
|
||||||
|
|
||||||
sessionId := "session-id"
|
|
||||||
assert.Nil(t, err)
|
|
||||||
_, err = db.Exec(`
|
|
||||||
INSERT INTO session (session_id, user_id, created_at, expires_at)
|
|
||||||
VALUES (?, ?, datetime(), datetime("now", "+1 day"))`, sessionId, userId)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/change-password", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Cookie", "id="+sessionId)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
html, err := html.Parse(resp.Body)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
csrfToken := findCsrfToken(html)
|
|
||||||
assert.NotEqual(t, "", csrfToken)
|
|
||||||
|
|
||||||
formData := url.Values{
|
|
||||||
"current-password": {"password"},
|
|
||||||
"new-password": {"insecure-password"},
|
|
||||||
"csrf-token": {csrfToken},
|
|
||||||
}
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/change-password", strings.NewReader(formData.Encode()))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Cookie", "id="+sessionId)
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
resp, err = httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
|
|
||||||
var rows int
|
|
||||||
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, rows)
|
|
||||||
})
|
|
||||||
t.Run("should change password and invalidate all other user sessions", func(t *testing.T) {
|
t.Run("should change password and invalidate all other user sessions", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1169,12 +987,6 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
pass = service.GetHashPassword("MyNewSecurePassword1!", []byte("salt"))
|
|
||||||
var rows int
|
|
||||||
err = db.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, rows)
|
|
||||||
|
|
||||||
var sessionIds []string
|
var sessionIds []string
|
||||||
sessions, err := db.Query(`SELECT session_id FROM session WHERE NOT user_id = ? ORDER BY session_id`, uuid.Nil)
|
sessions, err := db.Query(`SELECT session_id FROM session WHERE NOT user_id = ? ORDER BY session_id`, uuid.Nil)
|
||||||
assert.Nil(t, err)
|
assert.Nil(t, err)
|
||||||
@@ -1191,288 +1003,7 @@ func TestIntegrationAuth(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ForgotPasswordMail", func(t *testing.T) {
|
t.Run("ForgotPassword", func(t *testing.T) {
|
||||||
t.Run(`should redirect to "/" if signed in`, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
d, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
userId := uuid.New()
|
|
||||||
|
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
|
||||||
_, err := d.Exec(`
|
|
||||||
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
|
|
||||||
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
sessionId := "session-id"
|
|
||||||
_, err = d.Exec(`
|
|
||||||
INSERT INTO session (session_id, user_id, created_at, expires_at)
|
|
||||||
VALUES ("session-id", ?, datetime(), datetime("now", "+1 day"))`, userId)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Cookie", "id="+sessionId)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
|
||||||
assert.Equal(t, "/", resp.Header.Get("Location"))
|
|
||||||
})
|
|
||||||
t.Run(`should fail if csrf token is invalid`, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
d, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
userId := uuid.New()
|
|
||||||
|
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
|
||||||
_, err := d.Exec(`
|
|
||||||
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
|
|
||||||
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
anonymousSessionId := findCookie(resp, "id").Value
|
|
||||||
assert.NotEqual(t, "", anonymousSessionId)
|
|
||||||
|
|
||||||
formData := url.Values{
|
|
||||||
"email": {"mail@mail.de"},
|
|
||||||
"csrf-token": {"invalid-csrf-token"},
|
|
||||||
}
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password", strings.NewReader(formData.Encode()))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
resp, err = httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
|
|
||||||
var rows int
|
|
||||||
err = d.QueryRow("SELECT COUNT(*) FROM token WHERE user_id = ? AND type = ?", userId, types.TokenTypePasswordReset).Scan(&rows)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 0, rows)
|
|
||||||
})
|
|
||||||
t.Run(`should fail but respond with uniform message`, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
anonymousSessionId := findCookie(resp, "id").Value
|
|
||||||
assert.NotEqual(t, "", anonymousSessionId)
|
|
||||||
body, err := html.Parse(resp.Body)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
anonymousCsrfToken := findCsrfToken(body)
|
|
||||||
|
|
||||||
formData := url.Values{
|
|
||||||
"email": {"non-existent@mail.de"},
|
|
||||||
"csrf-token": {anonymousCsrfToken},
|
|
||||||
}
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password", strings.NewReader(formData.Encode()))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
req.Header.Set("Cookie", "id="+anonymousSessionId)
|
|
||||||
resp, err = httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
msg := "If the address exists, an email has been sent."
|
|
||||||
assert.Contains(t, resp.Header.Get("HX-Trigger"), msg)
|
|
||||||
})
|
|
||||||
t.Run(`should generate token and respond with uniform message`, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
|
|
||||||
userId := uuid.New()
|
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
|
||||||
_, err := db.Exec(`
|
|
||||||
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
|
|
||||||
VALUES (?, "mail@mail.de", TRUE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
anonymousSessionId := findCookie(resp, "id").Value
|
|
||||||
assert.NotEqual(t, "", anonymousSessionId)
|
|
||||||
body, err := html.Parse(resp.Body)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
anonymousCsrfToken := findCsrfToken(body)
|
|
||||||
|
|
||||||
formData := url.Values{
|
|
||||||
"email": {"mail@mail.de"},
|
|
||||||
"csrf-token": {anonymousCsrfToken},
|
|
||||||
}
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password", strings.NewReader(formData.Encode()))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
req.Header.Set("Cookie", "id="+anonymousSessionId)
|
|
||||||
resp, err = httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
|
||||||
msg := "If the address exists, an email has been sent."
|
|
||||||
assert.Contains(t, resp.Header.Get("HX-Trigger"), msg)
|
|
||||||
|
|
||||||
var rows int
|
|
||||||
err = db.QueryRow("SELECT COUNT(*) FROM token WHERE user_id = ? AND type = ?", userId, types.TokenTypePasswordReset).Scan(&rows)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, rows)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ForgotPasswordResponse", func(t *testing.T) {
|
|
||||||
t.Run(`should fail if token does not exist`, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
d, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
userId := uuid.New()
|
|
||||||
|
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
|
||||||
_, err := d.Exec(`
|
|
||||||
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
|
|
||||||
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
anonymousSessionId := findCookie(resp, "id").Value
|
|
||||||
html, err := html.Parse(resp.Body)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
anonymousCsrfToken := findCsrfToken(html)
|
|
||||||
assert.NotEqual(t, "", anonymousCsrfToken)
|
|
||||||
|
|
||||||
formData := url.Values{
|
|
||||||
"new-password": {"MyNewSecurePassword1!"},
|
|
||||||
"csrf-token": {anonymousCsrfToken},
|
|
||||||
}
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password-actual", strings.NewReader(formData.Encode()))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Cookie", "id="+anonymousSessionId)
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
req.Header.Set("HX-Current-URL", basePath+"/auth/change-password?token=invalidToken")
|
|
||||||
resp, err = httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
|
|
||||||
var rows int
|
|
||||||
err = d.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, rows)
|
|
||||||
})
|
|
||||||
t.Run(`should fail if token is outdated`, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
d, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
userId := uuid.New()
|
|
||||||
|
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
|
||||||
_, err := d.Exec(`
|
|
||||||
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
|
|
||||||
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
anonymousSessionId := findCookie(resp, "id").Value
|
|
||||||
html, err := html.Parse(resp.Body)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
anonymousCsrfToken := findCsrfToken(html)
|
|
||||||
assert.NotEqual(t, "", anonymousCsrfToken)
|
|
||||||
|
|
||||||
token := "password-reset-token"
|
|
||||||
_, err = d.Exec(`
|
|
||||||
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
|
|
||||||
VALUES (?, ?, ?, ?, datetime("now", "-16 minute"), datetime("now", "-1 minute"))`, token, userId, "", types.TokenTypePasswordReset)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
formData := url.Values{
|
|
||||||
"new-password": {"MyNewSecurePassword1!"},
|
|
||||||
"csrf-token": {anonymousCsrfToken},
|
|
||||||
}
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password-actual", strings.NewReader(formData.Encode()))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Cookie", "id="+anonymousSessionId)
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
req.Header.Set("HX-Current-URL", basePath+"/auth/change-password?token="+url.QueryEscape(token))
|
|
||||||
resp, err = httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
|
|
||||||
var rows int
|
|
||||||
err = d.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, rows)
|
|
||||||
})
|
|
||||||
t.Run(`should fail if password is insecure`, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
d, basePath, ctx := setupIntegrationTest(t)
|
|
||||||
userId := uuid.New()
|
|
||||||
|
|
||||||
pass := service.GetHashPassword("password", []byte("salt"))
|
|
||||||
_, err := d.Exec(`
|
|
||||||
INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at)
|
|
||||||
VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, userId, pass, []byte("salt"))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/forgot-password", nil)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
anonymousSessionId := findCookie(resp, "id").Value
|
|
||||||
html, err := html.Parse(resp.Body)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
anonymousCsrfToken := findCsrfToken(html)
|
|
||||||
assert.NotEqual(t, "", anonymousCsrfToken)
|
|
||||||
|
|
||||||
token := "password-reset-token"
|
|
||||||
_, err = d.Exec(`
|
|
||||||
INSERT INTO token (token, user_id, session_id, type, created_at, expires_at)
|
|
||||||
VALUES (?, ?, ?, ?, datetime("now"), datetime("now", "+15 minute"))`, token, userId, "", types.TokenTypePasswordReset)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
|
|
||||||
formData := url.Values{
|
|
||||||
"new-password": {"insecure-password"},
|
|
||||||
"csrf-token": {anonymousCsrfToken},
|
|
||||||
}
|
|
||||||
req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/forgot-password-actual", strings.NewReader(formData.Encode()))
|
|
||||||
assert.Nil(t, err)
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Cookie", "id="+anonymousSessionId)
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
req.Header.Set("HX-Current-URL", basePath+"/auth/change-password?token="+url.QueryEscape(token))
|
|
||||||
resp, err = httpClient.Do(req)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
|
|
||||||
var rows int
|
|
||||||
err = d.QueryRow("SELECT COUNT(*) FROM user WHERE user_id = ? AND password = ?", userId, pass).Scan(&rows)
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, rows)
|
|
||||||
})
|
|
||||||
t.Run("should change password and invalidate ALL sessions", func(t *testing.T) {
|
t.Run("should change password and invalidate ALL sessions", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
2239
package-lock.json
generated
2239
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "web-app-template",
|
"name": "me-fit",
|
||||||
"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 -i input.css -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 build -o static/css/tailwind.css --minify",
|
||||||
"watch": "mkdir -p static/js && cp -f node_modules/htmx.org/dist/htmx.min.js static/js/htmx.min.js && tailwindcss -i input.css -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 build -o static/css/tailwind.css --watch",
|
||||||
|
"test": ""
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"htmx.org": "2.0.6",
|
"htmx.org": "2.0.4",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "3.4.17",
|
||||||
"@tailwindcss/cli": "4.1.11"
|
"daisyui": "4.12.22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"web-app-template/db"
|
"me-fit/db"
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
mailTemplate "web-app-template/template/mail"
|
mailTemplate "me-fit/template/mail"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
@@ -249,7 +249,7 @@ func (service AuthImpl) SendVerificationMail(userId uuid.UUID, email string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
service.mail.SendMail(email, "Welcome to web-app-template", w.String())
|
service.mail.SendMail(email, "Welcome to ME-FIT", w.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service AuthImpl) VerifyUserEmail(tokenStr string) error {
|
func (service AuthImpl) VerifyUserEmail(tokenStr string) error {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/db"
|
"me-fit/db"
|
||||||
"web-app-template/mocks"
|
"me-fit/mocks"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -127,7 +127,7 @@ func TestSendVerificationMail(t *testing.T) {
|
|||||||
|
|
||||||
mockAuthDb.EXPECT().GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify).Return(tokens, nil)
|
mockAuthDb.EXPECT().GetTokensByUserIdAndType(userId, types.TokenTypeEmailVerify).Return(tokens, nil)
|
||||||
|
|
||||||
mockMail.EXPECT().SendMail(email, "Welcome to web-app-template", mock.MatchedBy(func(message string) bool {
|
mockMail.EXPECT().SendMail(email, "Welcome to ME-FIT", mock.MatchedBy(func(message string) bool {
|
||||||
return strings.Contains(message, token.Token)
|
return strings.Contains(message, token.Token)
|
||||||
})).Return()
|
})).Return()
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/db"
|
"me-fit/db"
|
||||||
"web-app-template/types"
|
"me-fit/types"
|
||||||
|
|
||||||
"errors"
|
"errors"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|||||||
26
tailwind.config.js
Normal file
26
tailwind.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["./template/**/*.templ", "./static/**/*.js"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
|
||||||
|
animation: {
|
||||||
|
fade: 'fadeOut 0.25s ease-in',
|
||||||
|
},
|
||||||
|
|
||||||
|
keyframes: _ => ({
|
||||||
|
fadeOut: {
|
||||||
|
'0%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('daisyui'),
|
||||||
|
],
|
||||||
|
daisyui: {
|
||||||
|
themes: ["retro"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ 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="flex items-center gap-2">
|
<label class="input input-bordered flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
class="grow"
|
class="grow"
|
||||||
@@ -24,7 +24,7 @@ templ DeleteAccountComp() {
|
|||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button class="self-end">
|
<button class="btn btn-error self-end">
|
||||||
Delete Account
|
Delete Account
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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="inline-block relative">
|
<div class="group inline-block relative">
|
||||||
<button class="font-semibold py-2 px-4 inline-flex items-center">
|
<button class="font-semibold py-2 px-4 inline-flex items-center">
|
||||||
<span class="mr-1">{ user }</span>
|
<span class="mr-1">{ user }</span>
|
||||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||||
@@ -11,20 +11,20 @@ templ UserComp(user string) {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="absolute hidden group-hover:block w-full">
|
<div class="absolute hidden group-hover:block w-full">
|
||||||
<ul class="w-fit float-right mr-4 p-3">
|
<ul class="menu bg-base-300 rounded-box w-fit float-right mr-4 p-3">
|
||||||
<li class="mb-1">
|
<li class="mb-1">
|
||||||
<a hx-post="/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="">Delete Account</a></li>
|
<li><a href="/auth/delete-account" class="text-error">Delete Account</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<a href="/auth/signup" class="">Sign Up</a>
|
<a href="/auth/signup" class="btn btn-sm">Sign Up</a>
|
||||||
<a href="/auth/signin" class="">Sign In</a>
|
<a href="/auth/signin" class="btn btn-sm">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="mt-8" hx-get="/api/auth/verify-resend" hx-sync="this:drop" hx-swap="outerHTML">
|
<button class="btn mt-8" hx-get="/api/auth/verify-resend" hx-sync="this:drop" hx-swap="outerHTML">
|
||||||
resend verification email
|
resend verification email
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ templ VerifyResponseComp(isVerified bool) {
|
|||||||
<p class="text-lg text-center">
|
<p class="text-lg text-center">
|
||||||
You have completed the verification process. Thank you!
|
You have completed the verification process. Thank you!
|
||||||
</p>
|
</p>
|
||||||
<a class="mt-8" href="/">
|
<a class="btn btn-primary mt-8" href="/">
|
||||||
Go Home
|
Go Home
|
||||||
</a>
|
</a>
|
||||||
} else {
|
} else {
|
||||||
@@ -20,7 +20,7 @@ templ VerifyResponseComp(isVerified bool) {
|
|||||||
<p class="text-lg text-center">
|
<p class="text-lg text-center">
|
||||||
Please try again by sign up process
|
Please try again by sign up process
|
||||||
</p>
|
</p>
|
||||||
<a class="mt-8" href="/auth/signup">
|
<a class="btn btn-primary mt-8" href="/auth/signup">
|
||||||
Sign Up
|
Sign Up
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
package template
|
package template
|
||||||
|
|
||||||
templ Index() {
|
templ Index() {
|
||||||
<div class="h-full">
|
<div class="hero bg-base-200 h-full">
|
||||||
<div class="text-center">
|
<div class="hero-content 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? web-app-template is the perfect
|
Ever wanted to track your workouts and see your progress over time? ME-FIT is the perfect
|
||||||
solution for you.
|
solution for you.
|
||||||
</p>
|
</p>
|
||||||
<a href="/workout" class="">Get Started</a>
|
<a href="/workout" class="btn btn-primary">Get Started</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
package template
|
package template
|
||||||
|
|
||||||
templ Layout(slot templ.Component, user templ.Component) {
|
templ Layout(slot templ.Component, user templ.Component, environment string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>web-app-template</title>
|
<title>ME-FIT</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" />
|
||||||
<meta
|
if environment == "prod" {
|
||||||
name="htmx-config"
|
<script defer src="https://umami.me-fit.eu/script.js" data-website-id="3c8efb09-44e4-4372-8a1e-c3bc675cd89a"></script>
|
||||||
content='{
|
}
|
||||||
|
<meta name="htmx-config" content='{
|
||||||
"includeIndicatorStyles": false,
|
"includeIndicatorStyles": false,
|
||||||
"selfRequestsOnly": true,
|
"selfRequestsOnly": true,
|
||||||
"allowScriptTags": false
|
"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 hx-headers='{"csrf-token": "CSRF_TOKEN"}'>
|
<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-sm">
|
<div class="flex justify-end items-center gap-2 py-1 px-2 h-12 md:gap-10 md:px-10 md:py-2 shadow">
|
||||||
<a href="/" class="flex-1 flex gap-2">
|
<a href="/" class="flex-1 flex gap-2">
|
||||||
<img src="/static/favicon.svg" alt="web-app-template logo"/>
|
<img src="/static/favicon.svg" alt="ME-FIT logo" />
|
||||||
<span>web-app-template</span>
|
<span>ME-FIT</span>
|
||||||
</a>
|
</a>
|
||||||
@user
|
@user
|
||||||
</div>
|
</div>
|
||||||
@@ -35,11 +37,12 @@ templ Layout(slot templ.Component, user templ.Component) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="" id="toasts">
|
<div class="toast" id="toasts">
|
||||||
<div class="hidden" id="toast">
|
<div class="hidden alert" id="toast">
|
||||||
New message arrived.
|
New message arrived.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ 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="p-16 rounded-lg">
|
<div class="bg-error p-16 rounded-lg">
|
||||||
<h1 class="text-4xl mb-5">Not Found</h1>
|
<h1 class="text-4xl text-error-content mb-5">Not Found</h1>
|
||||||
<p class="text-lg mb-5">The page you are looking for does not exist.</p>
|
<p class="text-lg text-error-content mb-5">The page you are looking for does not exist.</p>
|
||||||
<a href="/" class="">Go back to home</a>
|
<a href="/" class="btn btn-lg btn-primary">Go back to home</a>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,17 @@ package workout
|
|||||||
|
|
||||||
templ WorkoutComp(currentDate string) {
|
templ WorkoutComp(currentDate string) {
|
||||||
<main class="mx-2">
|
<main class="mx-2">
|
||||||
<form
|
<form class="max-w-xl mx-auto flex flex-col gap-4 justify-center mt-10" hx-post="/api/workout"
|
||||||
class="max-w-xl mx-auto flex flex-col gap-4 justify-center mt-10"
|
hx-target="#workout-placeholder" hx-swap="outerHTML">
|
||||||
hx-post="/api/workout"
|
|
||||||
hx-target="#workout-placeholder"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
|
||||||
<h2 class="text-4xl mb-8">Track your workout</h2>
|
<h2 class="text-4xl mb-8">Track your workout</h2>
|
||||||
<input id="date" type="date" class="" value={ currentDate } name="date"/>
|
<input id="date" type="date" class="input input-bordered" value={ currentDate } name="date" />
|
||||||
<select class="w-full" name="type">
|
<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 type="number" class="" placeholder="Sets" name="sets"/>
|
<input type="number" class="input input-bordered" placeholder="Sets" name="sets" />
|
||||||
<input type="number" class="" placeholder="Reps" name="reps"/>
|
<input type="number" class="input input-bordered" placeholder="Reps" name="reps" />
|
||||||
<button class="self-end">Save</button>
|
<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>
|
||||||
@@ -31,7 +27,7 @@ type Workout struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
templ WorkoutListComp(workouts []Workout) {
|
templ WorkoutListComp(workouts []Workout) {
|
||||||
<div class="overflow-x-auto mx-auto max-w-lg">
|
<div class="overflow-x-auto mx-auto max-w-screen-lg">
|
||||||
<h2 class="text-4xl mt-14 mb-8">Workout history</h2>
|
<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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Settings struct {
|
type Settings struct {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"web-app-template/log"
|
"me-fit/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) {
|
func TriggerToast(w http.ResponseWriter, r *http.Request, class string, message string, statusCode int) {
|
||||||
|
|||||||
Reference in New Issue
Block a user