2.0 - WebUI

This commit is contained in:
Evann Regnault 2025-03-15 01:49:52 +01:00
parent 23b80bc084
commit dab97b2de6
33 changed files with 1009 additions and 191 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
.env
.gitignore
.idea
.dockerignore
*.db
build/
tmp/
templates/*.go

3
.gitignore vendored
View file

@ -1,5 +1,8 @@
*.db *.db
.env
.idea .idea
tmp/
build/ build/
templates/*.go templates/*.go

20
Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM docker.io/golang:alpine AS build
LABEL authors="estym"
RUN apk add --no-cache gcc musl-dev ca-certificates git
RUN go install github.com/a-h/templ/cmd/templ@v0.3.833
RUN mkdir /app
COPY . /app
WORKDIR /app
RUN templ generate
RUN go install -ldflags='-s -w -extldflags "-static"' .
FROM scratch
COPY --from=build /go/bin/sonarqube-badge /usr/local/bin/sonarqube-badge
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENTRYPOINT ["/usr/local/bin/sonarqube-badge"]

View file

@ -1,5 +0,0 @@
all:
templ generate
mkdir -p build
cd build
go build -o build/sonarqube-badges .

46
README.md Normal file
View file

@ -0,0 +1,46 @@
# SonarQube Badges
![Quality Gate](https://sonarqube-badge.regnault.dev/project/badge/3/alert_status)
## Why?
SonarQube has a feature known as badges,
you must have seen those kind of badge on different kind of readme
for things such as CI status, Current version or event dependencies.
SonarQube has those but for the compliance of the codebase to some standards.
However, the links that SonarQube creates contains sensitive information such as a token that can be used for various things.
That is why, I decided to make an app that hides the tokens in order to be able to publicly share those badges.
## Build
In order to build this app, you will need to install [Templ](https://github.com/a-h/templ) to compile the templates
```shell
templ generate
go build .
```
## Configuration
This app has 3 environment variables which are the following:
- `DATA_PATH` : Location of the data folder of the application that'll contain the sqlite database.
- `SECRET` : A secret that'll be used to encrypt the SonarQube token in the database
- `SQ_HOST` : Your SonarQube instance URL
- `PORT` : The port of the web application `defaults to 8080`
## First run
On your first run, you'll get default credentials displayed as such:
```
Default User:
- admin@example.com
- adec2454d43489be2cdb758f645a4973ec785159546db83563dd16e6d650faa261c8b429dbdf8074f95cf4cdc118b8a21753d06412d2
```
You will be able to change those credential by accessing the `/user` path in the app and updating your profile
## Docker
[A docker image is available on my own Registry](https://registry.regnault.dev/#!/taglist/sonarqube-badge)

View file

@ -6,10 +6,10 @@ import (
) )
type Config struct { type Config struct {
SqHost string `envconfig:"SQ_HOST"` SqHost string `envconfig:"SQ_HOST"`
AppPassword string `envconfig:"APP_PASSWORD"` Secret string `envconfig:"SECRET"`
Secret string `envconfig:"SECRET"` DataPath string `envconfig:"DATA_PATH"`
DataPath string `envconfig:"DATA_PATH"` Port int `envconfig:"PORT"`
} }
func ProcessConfiguration(ctx context.Context) context.Context { func ProcessConfiguration(ctx context.Context) context.Context {

2
go.mod
View file

@ -14,10 +14,10 @@ require (
) )
require ( require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/mattn/go-sqlite3 v1.14.24 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.23.0 // indirect
) )

4
go.sum
View file

@ -8,6 +8,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@ -16,6 +18,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=

25
main.go
View file

@ -2,15 +2,40 @@ package main
import ( import (
"context" "context"
"github.com/joho/godotenv"
"os"
"os/signal"
"sonarqube-badge/config" "sonarqube-badge/config"
"sonarqube-badge/router" "sonarqube-badge/router"
"sonarqube-badge/store" "sonarqube-badge/store"
"syscall"
) )
func main() { func main() {
_ = godotenv.Load(".env")
if os.Getenv("DATA_PATH") == "" || os.Getenv("SECRET") == "" || os.Getenv("SQ_HOST") == "" {
println("Environment variables missing")
println("Please ensure that the following environment variables are set: ")
println("- DATA_PATH")
println("- SECRET")
println("- SQ_HOST")
os.Exit(1)
}
ctx := context.Background() ctx := context.Background()
ctx = config.ProcessConfiguration(ctx) ctx = config.ProcessConfiguration(ctx)
ctx = store.CreateDatabase(ctx) ctx = store.CreateDatabase(ctx)
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
signal.Notify(c, os.Interrupt, syscall.SIGQUIT)
signal.Notify(c, os.Interrupt, syscall.SIGKILL)
go func() {
<-c
println("Stopping server")
os.Exit(1)
}()
router.StartServer(ctx) router.StartServer(ctx)
} }

View file

@ -3,7 +3,7 @@ package api
import ( import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"net/http" "net/http"
"sonarqube-badge/security" "sonarqube-badge/router/utils"
) )
func postLogin(res http.ResponseWriter, req *http.Request) { func postLogin(res http.ResponseWriter, req *http.Request) {
@ -14,20 +14,16 @@ func postLogin(res http.ResponseWriter, req *http.Request) {
email := req.Form.Get("email") email := req.Form.Get("email")
password := req.Form.Get("password") password := req.Form.Get("password")
if email == "1@example.com" && password == "hello" { ctx := req.Context()
token, err := security.CreateToken(email)
if utils.UserExists(ctx, email, password) {
user := utils.GetUser(ctx, email)
cookie, err := utils.CreateJWTCookie(user, req)
if err != nil { if err != nil {
http.Error(res, err.Error(), http.StatusInternalServerError) res.WriteHeader(http.StatusInternalServerError)
return return
} }
cookie := http.Cookie{ http.SetCookie(res, cookie)
Name: "jwt-token",
Value: token,
Path: "/",
Secure: true,
HttpOnly: true,
}
http.SetCookie(res, &cookie)
} else { } else {
res.WriteHeader(http.StatusUnauthorized) res.WriteHeader(http.StatusUnauthorized)
res.Write([]byte("Wrong email or password")) res.Write([]byte("Wrong email or password"))

90
router/api/project.go Normal file
View file

@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"github.com/gorilla/mux"
"net/http"
"sonarqube-badge/router/middlewares"
"sonarqube-badge/router/utils"
"sonarqube-badge/security/aes"
"sonarqube-badge/store"
"strconv"
)
type ProjectPayload struct {
Name string `json:"name"`
Token string `json:"token"`
}
func setProject(w http.ResponseWriter, r *http.Request) {
db, cfg, _, isError := utils.VerifyUser(w, r)
if isError {
return
}
idString := mux.Vars(r)["id"]
id, err := strconv.Atoi(idString)
payload := ProjectPayload{}
err = json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
project := store.Project{}
db.Where("id = ?", id).First(&project)
if project.ID == 0 {
w.WriteHeader(http.StatusNotFound)
}
project.ProjectName = payload.Name
if project.Token != payload.Token {
project.Token = aes.EncryptAES(cfg.Secret, payload.Token)
}
db.Save(&project)
}
func createProject(w http.ResponseWriter, r *http.Request) {
db, _, user, isError := utils.VerifyUser(w, r)
if isError {
return
}
project := user.CreateProject(db)
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(project)
}
func deleteProject(w http.ResponseWriter, r *http.Request) {
db, _, user, isError := utils.VerifyUser(w, r)
if isError {
return
}
projectIdString := mux.Vars(r)["id"]
projectId, err := strconv.Atoi(projectIdString)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
project := store.Project{}
db.Where("id = ? AND owner_id = ?", projectId, user.ID).First(&project)
if project.ID == 0 {
w.WriteHeader(http.StatusNotFound)
return
}
db.Delete(&project)
w.WriteHeader(http.StatusOK)
}
func ProjectRouter(r *mux.Router) {
sr := r.PathPrefix("").Subrouter()
sr.Use(middlewares.CheckJwtToken)
sr.HandleFunc("", createProject).Methods(http.MethodPost)
sr.HandleFunc("/{id}", setProject).Methods(http.MethodPost)
sr.HandleFunc("/{id}", deleteProject).Methods(http.MethodDelete)
}

View file

@ -1,99 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"github.com/gorilla/mux"
"gorm.io/gorm"
"io"
"net/http"
"os"
"sonarqube-badge/config"
"sonarqube-badge/router/middlewares"
"sonarqube-badge/security"
"sonarqube-badge/store"
)
type setProjectBadgePayload struct {
Token string `json:"token"`
}
func getProjectBadge(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
projectName := vars["name"]
metric := vars["metric"]
print(projectName)
project := store.SQProject{}
ctx := r.Context()
cfg := ctx.Value("config").(config.Config)
ctx.Value("db").(*gorm.DB).First(&project, "project_name = ?", projectName)
if project.ID == 0 {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("project not found"))
return
}
token := security.DecryptAES(cfg.Secret, project.Token)
fmt.Printf("%s", token)
url := fmt.Sprintf("%s/api/project_badges/measure?metric=%s&project=%s&token=%s", cfg.SqHost, metric, projectName, token)
request, err := http.Get(url)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
os.Stderr.Write([]byte(err.Error()))
w.Write([]byte("internal server error"))
return
}
bytes, err := io.ReadAll(request.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
os.Stderr.Write([]byte(err.Error()))
w.Write([]byte("internal server error"))
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.WriteHeader(request.StatusCode)
w.Write(bytes)
}
func setProjectBadge(w http.ResponseWriter, r *http.Request) {
var payload setProjectBadgePayload
vars := mux.Vars(r)
projectName := vars["name"]
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
os.Stderr.Write([]byte(err.Error()))
w.Write([]byte("invalid payload"))
return
}
ctx := r.Context()
db := ctx.Value("db").(*gorm.DB)
if !middlewares.CheckAuth(ctx, r) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("unauthorized"))
return
}
project := store.SQProject{}
db.First(&project, "project_name = ?", projectName)
project.ProjectName = projectName
project.Token = security.EncryptAES(ctx.Value("config").(config.Config).Secret, payload.Token)
db.Save(&project)
w.WriteHeader(http.StatusOK)
w.Write([]byte("Successfully added"))
}
func ProjectBadgeRouter(r *mux.Router) {
r.HandleFunc("/{name}/{metric}", getProjectBadge).Methods("GET")
r.HandleFunc("/{name}", setProjectBadge).Methods("POST")
}

81
router/api/user.go Normal file
View file

@ -0,0 +1,81 @@
package api
import (
"github.com/gorilla/mux"
"gorm.io/gorm"
"net/http"
"sonarqube-badge/router/middlewares"
"sonarqube-badge/router/utils"
"sonarqube-badge/store"
)
func changePassword(w http.ResponseWriter, r *http.Request) {
db, _, user, done := utils.VerifyUser(w, r)
if done {
return
}
password := r.FormValue("password")
verifyPassword := r.FormValue("verify_password")
if password != verifyPassword {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("passwords do not match"))
return
}
user.ChangePassword(db, password)
refreshCookie(db, user.ID, r, w)
w.WriteHeader(http.StatusNoContent)
}
func changeEmail(w http.ResponseWriter, r *http.Request) {
db, _, user, done := utils.VerifyUser(w, r)
if done {
return
}
newEmail := r.FormValue("email")
if user.ChangeEmail(db, newEmail) != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
user.Email = newEmail
refreshCookie(db, user.ID, r, w)
w.WriteHeader(http.StatusNoContent)
}
func changeUsername(w http.ResponseWriter, r *http.Request) {
db, _, user, done := utils.VerifyUser(w, r)
if done {
return
}
newUsername := r.FormValue("username")
user.ChangeUsername(db, newUsername)
refreshCookie(db, user.ID, r, w)
w.WriteHeader(http.StatusNoContent)
}
func refreshCookie(db *gorm.DB, userId uint, r *http.Request, w http.ResponseWriter) {
var user store.User
db.First(&user, userId)
cookie, err := utils.CreateJWTCookie(&user, r)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
http.SetCookie(w, cookie)
}
func UserRouter(r *mux.Router) {
sr := r.PathPrefix("/").Subrouter()
sr.Use(middlewares.CheckJwtToken)
sr.HandleFunc("/password", changePassword).Methods("POST")
sr.HandleFunc("/email", changeEmail).Methods("POST")
sr.HandleFunc("/username", changeUsername).Methods("POST")
}

View file

@ -1,40 +1,29 @@
package middlewares package middlewares
import ( import (
"context"
"net/http" "net/http"
"sonarqube-badge/config" "sonarqube-badge/security/jwt"
"sonarqube-badge/security"
"strings"
) )
func CheckAuth(ctx context.Context, r *http.Request) bool {
config := ctx.Value("config").(config.Config)
if r.Header.Get("Authorization") == "" {
return false
}
token := r.Header.Get("Authorization")
if strings.HasPrefix(token, "Bearer ") {
token = token[7:]
} else {
return false
}
return token == config.AppPassword
}
func CheckJwtToken(h http.Handler) http.Handler { func CheckJwtToken(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenCookie, err := r.Cookie("jwt-token") tokenCookie, err := r.Cookie("jwt-token")
if err != nil || tokenCookie == nil { if err != nil || tokenCookie == nil {
w.WriteHeader(http.StatusUnauthorized) if r.Method == "GET" {
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
} else {
w.WriteHeader(http.StatusUnauthorized)
}
return return
} }
_, err = security.VerifyToken(tokenCookie.Value) _, err = jwt.VerifyToken(tokenCookie.Value, r.Context())
if err != nil { if err != nil {
w.WriteHeader(http.StatusUnauthorized) if r.Method == "GET" {
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
} else {
w.WriteHeader(http.StatusUnauthorized)
}
return return
} }

View file

@ -2,10 +2,12 @@ package router
import ( import (
"context" "context"
"fmt"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"net" "net"
"net/http" "net/http"
"sonarqube-badge/config"
"sonarqube-badge/router/api" "sonarqube-badge/router/api"
"sonarqube-badge/router/views" "sonarqube-badge/router/views"
) )
@ -14,24 +16,38 @@ func StartServer(ctx context.Context) {
r := mux.NewRouter() r := mux.NewRouter()
// API ROUTES // API ROUTES
api.ProjectBadgeRouter(r.PathPrefix("/api/project/badge").Subrouter()) api.ProjectRouter(r.PathPrefix("/api/project").Subrouter())
api.LoginRouter(r.PathPrefix("/api/login").Subrouter()) api.LoginRouter(r.PathPrefix("/api/login").Subrouter())
api.UserRouter(r.PathPrefix("/api/user").Subrouter())
// VIEW ROUTES // VIEW ROUTES
views.IndexRouter(r) views.IndexRouter(r)
views.LoginRouter(r) views.LoginRouter(r)
views.ProjectRouter(r)
views.BadgeRouter(r)
views.UserRouter(r)
credentials := handlers.AllowCredentials() credentials := handlers.AllowCredentials()
methods := handlers.AllowedMethods([]string{"POST", "GET", "OPTIONS"}) methods := handlers.AllowedMethods([]string{"POST", "GET", "DELETE"})
ttl := handlers.MaxAge(3600) ttl := handlers.MaxAge(3600)
port := ctx.Value("config").(config.Config).Port
if port == 0 {
port = 8080
}
fmt.Printf("Starting server on http://localhost:%d ...", port)
server := http.Server{ server := http.Server{
Addr: ":8080", Addr: fmt.Sprintf(":%d", port),
BaseContext: func(listener net.Listener) context.Context { BaseContext: func(listener net.Listener) context.Context {
return ctx return ctx
}, },
Handler: handlers.CORS(credentials, methods, ttl)(r), Handler: handlers.CORS(credentials, methods, ttl)(r),
} }
server.ListenAndServe() err := server.ListenAndServe()
if err != nil {
panic(err)
}
} }

37
router/utils/jwtUtils.go Normal file
View file

@ -0,0 +1,37 @@
package utils
import (
"github.com/golang-jwt/jwt/v5"
"net/http"
jwt2 "sonarqube-badge/security/jwt"
"sonarqube-badge/store"
)
func GetToken(req *http.Request) (*jwt.Token, error) {
cookie, err := req.Cookie("jwt-token")
if err != nil {
return nil, err
}
token, err := jwt2.VerifyToken(cookie.Value, req.Context())
if err != nil {
return nil, err
}
return token, nil
}
func CreateJWTCookie(user *store.User, req *http.Request) (*http.Cookie, error) {
token, err := jwt2.CreateToken(*user, req.Context())
if err != nil {
return nil, err
}
cookie := http.Cookie{
Name: "jwt-token",
Value: token,
Path: "/",
Secure: true,
HttpOnly: true,
}
return &cookie, nil
}

26
router/utils/userUtils.go Normal file
View file

@ -0,0 +1,26 @@
package utils
import (
"context"
"crypto/sha1"
"fmt"
"gorm.io/gorm"
"sonarqube-badge/store"
)
func UserExists(ctx context.Context, email string, password string) bool {
db := ctx.Value("db").(*gorm.DB)
passwordHash := sha1.Sum([]byte(password))
user := store.User{}
db.Where("email = ? AND password = ?", email, fmt.Sprintf("%x", passwordHash)).First(&user)
return user.ID != 0
}
func GetUser(ctx context.Context, email string) *store.User {
db := ctx.Value("db").(*gorm.DB)
user := store.User{}
db.Where("email = ?", email).First(&user)
return &user
}

29
router/utils/verify.go Normal file
View file

@ -0,0 +1,29 @@
package utils
import (
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
"net/http"
"sonarqube-badge/config"
"sonarqube-badge/store"
)
func VerifyUser(w http.ResponseWriter, r *http.Request) (*gorm.DB, config.Config, *store.User, bool) {
ctx := r.Context()
cfg := ctx.Value("config").(config.Config)
db := ctx.Value("db").(*gorm.DB)
token, err := GetToken(r)
if err != nil {
w.WriteHeader(http.StatusForbidden)
return nil, cfg, nil, true
}
email := token.Claims.(jwt.MapClaims)["email"].(string)
user := GetUser(ctx, email)
if user == nil {
w.WriteHeader(http.StatusNotFound)
return nil, cfg, nil, true
}
return db, cfg, user, false
}

93
router/views/badges.go Normal file
View file

@ -0,0 +1,93 @@
package views
import (
"fmt"
"github.com/gorilla/mux"
"gorm.io/gorm"
"io"
"net/http"
"os"
"sonarqube-badge/config"
"sonarqube-badge/security/aes"
"sonarqube-badge/store"
"strconv"
)
func postProjectBadge(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
projectIdString := vars["id"]
projectId, err := strconv.Atoi(projectIdString)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
metric := r.FormValue("type")
db := r.Context().Value("db").(*gorm.DB)
cfg := r.Context().Value("config").(config.Config)
var project store.Project
db.First(&project, projectId)
if project.ID == 0 {
w.WriteHeader(http.StatusNotFound)
return
}
projectToken := aes.DecryptAES(cfg.Secret, project.Token)
requestProjectImage(w, cfg, project.ProjectName, projectToken, metric)
}
func getProjectBadge(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
projectIdString := vars["id"]
projectId, err := strconv.Atoi(projectIdString)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
metric := vars["metric"]
ctx := r.Context()
db := ctx.Value("db").(*gorm.DB)
cfg := ctx.Value("config").(config.Config)
project := store.Project{}
db.First(&project, projectId)
if project.ID == 0 {
w.WriteHeader(http.StatusNotFound)
return
}
token := aes.DecryptAES(cfg.Secret, project.Token)
requestProjectImage(w, cfg, project.ProjectName, token, metric)
}
func requestProjectImage(w http.ResponseWriter, cfg config.Config, projectName string, projectToken string, metric string) {
url := fmt.Sprintf("%s/api/project_badges/measure?metric=%s&project=%s&token=%s", cfg.SqHost, metric, projectName, projectToken)
request, err := http.Get(url)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
os.Stderr.Write([]byte(err.Error()))
w.Write([]byte("internal server error"))
return
}
bytes, err := io.ReadAll(request.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
os.Stderr.Write([]byte(err.Error()))
w.Write([]byte("internal server error"))
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.WriteHeader(request.StatusCode)
w.Write(bytes)
}
func BadgeRouter(r *mux.Router) {
r.HandleFunc("/project/badge/{id}/{metric}", getProjectBadge).Methods("GET")
r.HandleFunc("/project/badge/{id}", postProjectBadge).Methods("POST")
}

View file

@ -4,11 +4,13 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"net/http" "net/http"
"sonarqube-badge/router/middlewares" "sonarqube-badge/router/middlewares"
"sonarqube-badge/router/utils"
"sonarqube-badge/templates" "sonarqube-badge/templates"
) )
func getIndex(res http.ResponseWriter, req *http.Request) { func getIndex(res http.ResponseWriter, req *http.Request) {
templates.Layout(templates.Index(), "Index").Render(req.Context(), res) token, _ := utils.GetToken(req)
templates.Layout(templates.Index(token), "Projects", req).Render(req.Context(), res)
} }
func IndexRouter(r *mux.Router) { func IndexRouter(r *mux.Router) {

View file

@ -3,13 +3,30 @@ package views
import ( import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"net/http" "net/http"
"sonarqube-badge/router/utils"
"sonarqube-badge/templates" "sonarqube-badge/templates"
) )
func getLogin(res http.ResponseWriter, req *http.Request) { func getLogin(res http.ResponseWriter, req *http.Request) {
templates.Layout(templates.Login(), "Login").Render(req.Context(), res) if _, err := utils.GetToken(req); err == nil {
http.Redirect(res, req, "/", http.StatusFound)
return
}
templates.Layout(templates.Login(), "Login", req).Render(req.Context(), res)
}
func getDisconnect(res http.ResponseWriter, req *http.Request) {
disconnectCookie := http.Cookie{
Name: "jwt-token",
MaxAge: -1,
Value: "",
}
http.SetCookie(res, &disconnectCookie)
http.Redirect(res, req, "/login", 302)
} }
func LoginRouter(r *mux.Router) { func LoginRouter(r *mux.Router) {
r.HandleFunc("/login", getLogin).Methods("GET") r.HandleFunc("/login", getLogin).Methods("GET")
r.HandleFunc("/disconnect", getDisconnect).Methods("GET")
} }

71
router/views/project.go Normal file
View file

@ -0,0 +1,71 @@
package views
import (
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/mux"
"gorm.io/gorm"
"net/http"
"sonarqube-badge/router/middlewares"
"sonarqube-badge/router/utils"
"sonarqube-badge/templates"
"strconv"
)
func getProjects(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
db := ctx.Value("db").(*gorm.DB)
token, err := utils.GetToken(r)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
email := token.Claims.(jwt.MapClaims)["email"].(string)
user := utils.GetUser(ctx, email)
if user == nil {
w.WriteHeader(http.StatusNotFound)
return
}
templates.ProjectList(user.GetProjects(db)).Render(ctx, w)
}
func getProject(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
db := ctx.Value("db").(*gorm.DB)
idString := mux.Vars(r)["id"]
id, err := strconv.Atoi(idString)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
token, _ := utils.GetToken(r)
email := token.Claims.(jwt.MapClaims)["email"].(string)
user := utils.GetUser(ctx, email)
if user == nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
project := user.GetProject(db, id)
if project.ID == 0 {
http.Redirect(w, r, "/", http.StatusFound)
return
}
name := project.ProjectName
if name == "" {
name = "Untitled Project"
}
templates.Layout(templates.Project(project), "Project - "+name, r).Render(ctx, w)
}
func ProjectRouter(router *mux.Router) {
sr := router.PathPrefix("").Subrouter()
sr.Use(middlewares.CheckJwtToken)
sr.HandleFunc("/project", getProjects).Methods("GET")
sr.HandleFunc("/project/{id}", getProject).Methods("GET")
}

24
router/views/user.go Normal file
View file

@ -0,0 +1,24 @@
package views
import (
"github.com/gorilla/mux"
"net/http"
"sonarqube-badge/router/middlewares"
"sonarqube-badge/router/utils"
"sonarqube-badge/templates"
)
func getUser(w http.ResponseWriter, r *http.Request) {
_, _, user, isError := utils.VerifyUser(w, r)
if isError {
return
}
templates.Layout(templates.User(*user), "User Profile", r).Render(r.Context(), w)
}
func UserRouter(r *mux.Router) {
sr := r.PathPrefix("").Subrouter()
sr.Use(middlewares.CheckJwtToken)
sr.HandleFunc("/user", getUser).Methods("GET")
}

View file

@ -1,4 +1,4 @@
package security package aes
import ( import (
"crypto/sha256" "crypto/sha256"

View file

@ -1,26 +1,29 @@
package security package jwt
import ( import (
"context"
"errors" "errors"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"sonarqube-badge/config"
"sonarqube-badge/store"
"time" "time"
) )
type JwtClaims struct { func CreateToken(user store.User, ctx context.Context) (string, error) {
}
func CreateToken(username string) (string, error) {
t := jwt.NewWithClaims(jwt.SigningMethodHS256, t := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{ jwt.MapClaims{
"username": username, "username": user.Username,
"email": user.Email,
"exp": time.Now().Add(time.Hour * 24).Unix(), "exp": time.Now().Add(time.Hour * 24).Unix(),
}) })
return t.SignedString([]byte("secret")) secret := ctx.Value("config").(config.Config).Secret
return t.SignedString([]byte(secret))
} }
func VerifyToken(jwtString string) (*jwt.Token, error) { func VerifyToken(jwtString string, ctx context.Context) (*jwt.Token, error) {
secret := ctx.Value("config").(config.Config).Secret
parse, err := jwt.Parse(jwtString, func(token *jwt.Token) (interface{}, error) { parse, err := jwt.Parse(jwtString, func(token *jwt.Token) (interface{}, error) {
return []byte("secret"), nil return []byte(secret), nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -2,26 +2,16 @@ package store
import ( import (
"context" "context"
"crypto/sha1"
"fmt" "fmt"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"os"
"sonarqube-badge/config" "sonarqube-badge/config"
"sonarqube-badge/security/aes"
"time"
) )
type SQProject struct {
gorm.Model
ProjectName string `gorm:"unique"`
Token string `gorm:"unique"`
}
type User struct {
gorm.Model
Username string
Password string
Email string
Salt string
}
func CreateDatabase(ctx context.Context) context.Context { func CreateDatabase(ctx context.Context) context.Context {
cfg := ctx.Value("config").(config.Config) cfg := ctx.Value("config").(config.Config)
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("%s/sqbadge.db", cfg.DataPath)), &gorm.Config{}) db, err := gorm.Open(sqlite.Open(fmt.Sprintf("%s/sqbadge.db", cfg.DataPath)), &gorm.Config{})
@ -29,10 +19,33 @@ func CreateDatabase(ctx context.Context) context.Context {
panic("failed to open database") panic("failed to open database")
} }
err = db.AutoMigrate(&SQProject{}) err = db.AutoMigrate(&Project{})
if err != nil { if err != nil {
panic("failed to migrate database") panic("failed to migrate database Project")
} }
err = db.AutoMigrate(&User{})
if err != nil {
panic("failed to migrate database User")
}
provideDefaultUser(cfg, db)
return context.WithValue(ctx, "db", db) return context.WithValue(ctx, "db", db)
} }
func provideDefaultUser(cfg config.Config, db *gorm.DB) {
password := aes.EncryptAES(cfg.Secret, time.Now().String())
sha1Hash := sha1.Sum([]byte(password))
user := User{
Username: "admin",
// Random Password
Password: fmt.Sprintf("%x", sha1Hash),
Email: "admin@example.com",
}
if db.First(&user).Error != nil {
_, _ = fmt.Fprintf(os.Stderr, "Default User: \n\t- %s\n\t- %s\n", user.Email, password)
db.Create(&user)
}
}

21
store/project.go Normal file
View file

@ -0,0 +1,21 @@
package store
import "gorm.io/gorm"
type Project struct {
gorm.Model
ProjectName string
Token string
OwnerId uint
Owner User `gorm:foreignkey:"user_id"`
}
func (project *Project) GetUser(db *gorm.DB) *User {
var user User
db.Where("id = ?", project.OwnerId).First(&user)
return &user
}
func (project *Project) Delete(db *gorm.DB) {
db.Where("id = ?", project.ID).Delete(&Project{})
}

59
store/user.go Normal file
View file

@ -0,0 +1,59 @@
package store
import (
"crypto/sha1"
"fmt"
"gorm.io/gorm"
)
type User struct {
gorm.Model
Username string
Password string
Email string `gorm:"unique"`
}
func (user *User) GetProjects(db *gorm.DB) []Project {
var projects []Project
db.Where(&Project{OwnerId: user.ID}).Find(&projects)
return projects
}
func (user *User) GetProject(db *gorm.DB, id int) Project {
var project Project
db.Where("id = ? AND owner_id = ?", id, user.ID).First(&project)
return project
}
func (user *User) CreateProject(db *gorm.DB) Project {
var project Project
project.OwnerId = user.ID
db.Create(&project)
return project
}
func (user *User) ChangeUsername(db *gorm.DB, username string) {
user.Username = username
db.Save(&user)
}
func (user *User) ChangeEmail(db *gorm.DB, email string) error {
user.Email = email
return db.Save(&user).Error
}
func (user *User) ChangePassword(db *gorm.DB, password string) {
sha1Sum := sha1.Sum([]byte(password))
user.Password = fmt.Sprintf("%x", sha1Sum)
db.Save(&user)
}
func CreateUser(db *gorm.DB, email string, password string, username string) User {
user := User{}
user.Username = username
sha1Hash := sha1.Sum([]byte(password))
user.Password = fmt.Sprintf("%x", sha1Hash)
user.Email = email
db.Create(&user)
return user
}

View file

@ -1,5 +1,34 @@
package templates package templates
templ Index() { import "github.com/golang-jwt/jwt/v5"
<form class></form>
} templ Index(token *jwt.Token) {
{{
username := ""
if claims, ok := token.Claims.(jwt.MapClaims); ok {
username = claims["username"].(string)
}
}}
<div class="container">
<h2 class="my-4">Welcome {username}</h2>
<div class="container">
<h3 class="my-2">Projects</h3>
<div hx-get="/project" hx-trigger="load"></div>
<a hx-post="/api/project" id="projectAdder">
<button class="btn btn-primary" type="button" >Create project</button>
</a>
</div>
</div>
<script type="application/javascript">
document.getElementById('projectAdder').addEventListener('htmx:beforeSwap', function (ev) {
ev.detail.shouldSwap = false;
});
document.getElementById('projectAdder').addEventListener('htmx:afterRequest', function (ev) {
if (ev.detail.successful) {
const id = JSON.parse(ev.detail.xhr.response).ID;
location.href = `/project/${id}`;
}
});
</script>
}

View file

@ -1,29 +1,62 @@
package templates package templates
import "net/http"
import "sonarqube-badge/router/utils"
import "github.com/golang-jwt/jwt/v5"
import "strings"
func isActive(title string, key string) string {
if strings.HasPrefix(title, key) {
return "active"
}
return ""
}
templ head(title string) { templ head(title string) {
<head> <head>
<title>{ title }</title> <title>{ title }</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</head> </head>
} }
templ header() { templ header(loggedIn bool, title string) {
<nav class="navbar navbar-expand-lg navbar-dark bg-dark p-2"> <nav class="navbar navbar-expand-lg bg-primary p-2">
<a class="navbar-brand">Sonarqube Badges</a> <a class="navbar-brand" href="/">Sonarqube Badges</a>
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
if loggedIn {
<li class="nav-item">
<a class={"nav-link " + isActive(title, "Project")} href="/">Projects</a>
</li>
<li class="nav-item">
<a class={"nav-link " + isActive(title, "User Profile")} href="/user">Profile</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/disconnect">Disconnect</a>
</li>
}
</ul>
</nav> </nav>
} }
templ Layout(component templ.Component, title string) { templ layout(component templ.Component, title string, token *jwt.Token) {
<html lang="en"> <html lang="en" data-bs-theme="dark">
@head(title) @head(title)
<body hx-ext="response-targets"> <body hx-ext="response-targets">
@header() @header(token != nil, title)
<div class="container-fluid m-4"> <div class="container-fluid m-4">
@component @component
</div> </div>
</body> </body>
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-response-targets@2.0.3" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous"></script> <script src="https://unpkg.com/htmx-ext-json-enc@2.0.1/json-enc.js"></script>
<script src="https://unpkg.com/htmx-ext-response-targets@2.0.3" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous"></script>
<script>htmx.config.responseTargetUnsetsError = false</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</html> </html>
}
func Layout(component templ.Component, title string, req *http.Request) templ.Component {
token, _ := utils.GetToken(req)
return layout(component, title, token);
} }

View file

@ -2,17 +2,18 @@ package templates
templ Login() { templ Login() {
<div class="container"> <div class="container">
<h2>Connection</h2>
<form hx-post="/api/login" hx-target-error="#loginError" id="loginForm"> <form hx-post="/api/login" hx-target-error="#loginError" id="loginForm">
<div hx-trigger="changed" id="loginError"></div> <div hx-trigger="changed" id="loginError"></div>
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Email Address</label> <label for="email" class="form-label">Email Address</label>
<input name="email" class="form-control" type="email" id="email"> <input name="email" class="form-control" type="email" id="email" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label">Password</label> <label for="password" class="form-label">Password</label>
<input name="password" class="form-control" type="password" id="password"> <input name="password" class="form-control" type="password" id="password" required>
</div> </div>
<button class="btn btn-primary">Connect</button> <button class="btn btn-primary" type="submit">Connect</button>
</form> </form>
<script type="application/javascript"> <script type="application/javascript">

129
templates/project.templ Normal file
View file

@ -0,0 +1,129 @@
package templates
import "sonarqube-badge/store"
import "fmt"
templ ProjectList(projects []store.Project) {
<ul>
for _, item := range projects {
<li>
<a href={ templ.SafeURL(fmt.Sprintf("/project/%d", item.ID))}>
if item.ProjectName != "" {
{ item.ProjectName }
} else {
Untitled Project
}
</a>
</li>
}
</ul>
}
templ Project(project store.Project) {
@templ.JSONScript("projectID", project.ID)
<div class="container">
<div class="my-4">
if project.ProjectName != "" {
<h2>{project.ProjectName}</h2>
} else {
<h2>Untitled Project</h2>
}
</div>
@EditProject(project)
@BadgeMaker(project)
@DangerZone(project)
</div>
}
templ EditProject(project store.Project) {
<div class="container mb-4">
<form id="editForm" hx-post={fmt.Sprintf("/api/project/%d", project.ID)} hx-ext="json-enc">
<h3>Project Edition</h3>
<div class="mb-2">
<label class="form-label" for="projectName">Project Name</label>
<input class="form-control" name="name" id="projectName" value={project.ProjectName}>
</div>
<div class="mb-2">
<label class="form-label" for="projectToken">Project Token</label>
<input class="form-control" name="token" id="projectToken" value={project.Token} type="password">
</div>
<button class="btn btn-primary">Submit</button>
</form>
</div>
<script>
document.getElementById('editForm').addEventListener('htmx:afterRequest', function (ev) {
if (ev.detail.successful) {
location.reload()
}
});
</script>
}
templ BadgeMaker(project store.Project) {
<div class="container mb-4">
<div>
<h3>Badge maker</h3>
<label class="form-label" for="badgeType">Type of badge</label>
<select class="form-control"
id="badgeType"
name="type"
hx-target="#image"
hx-target-error="#image"
hx-post={fmt.Sprintf("/project/badge/%d", project.ID)}>
<option value="coverage">Coverage</option>
<option value="duplicated_lines_density">Duplicated Lines(%)</option>
<option value="ncloc">Lines of Code</option>
<option selected="selected" value="alert_status">Quality Gate Status</option>
<option value="security_hotspots">Security Hotspots</option>
<option value="software_quality_reliability_issues">Reliability Issues</option>
<option value="software_quality_maintainability_issues">Maintainability Issues</option>
<option value="software_quality_reliability_issues">Reliability Issues</option>
<option value="software_quality_security_issues">Security Issues</option>
<option value="software_quality_maintainability_rating">Maintainability Rating</option>
<option value="software_quality_reliability_rating">Reliability Rating</option>
<option value="software_quality_security_rating">Security Rating</option>
<option value="software_quality_maintainability_remediation_effort">Technical Debt</option>
</select>
</div>
<div class="my-2">
<label class="form-label" for="imageURL">Image URL</label>
<div class="mb-2" id="image" hx-trigger="load" hx-get={fmt.Sprintf("/project/badge/%d/alert_status", project.ID)}>
</div>
<input class="form-control disabled" id="imageURL" readonly>
</div>
</div>
<script>
const projectId = parseInt(document.getElementById("projectID").textContent);
const badgeType = document.getElementById("badgeType");
const imageUrl = document.getElementById("imageURL");
function resetImage() {
imageUrl.value = `${location.origin}/project/badge/${projectId}/${badgeType.value}`
}
resetImage()
badgeType.addEventListener('htmx:afterRequest', function (ev) {
if (ev.detail.successful){
resetImage()
}
})
</script>
}
templ DangerZone(project store.Project) {
<div class="container">
<h3>Danger Zone</h3>
<button class="btn btn-danger" id="deleteProject" hx-delete={fmt.Sprintf("/api/project/%d", project.ID)} hx-confirm="Are you sure to delete this project?">Delete Project</button>
</div>
<script>
document.getElementById('deleteProject').addEventListener('htmx:afterRequest', function (ev) {
if (ev.detail.successful) {
location.href = "/"
}
});
</script>
}

56
templates/user.templ Normal file
View file

@ -0,0 +1,56 @@
package templates
import "sonarqube-badge/store"
templ User(user store.User) {
<div class="container">
<h2 class="my-4">Profile</h2>
<div class="mb-4">
<form id="nameForm" hx-post="/api/user/username">
<label for="name" class="form-label">Username</label>
<input id="name" class="form-control" name="username" value={user.Username}>
<button class="btn btn-primary mt-2">Change Username</button>
</form>
</div>
<div class="mb-4">
<form id="emailForm" hx-post="/api/user/email">
<label for="email" class="form-label">Email</label>
<input id="email" class="form-control" name="email" value={user.Email}>
<button class="btn btn-primary mt-2">Change Email</button>
</form>
</div>
<div class="mb-4">
<form id="passwordForm" hx-post="/api/user/password">
<label for="password" class="form-label">Password</label>
<input id="password" class="form-control" name="password" type="password" value>
<label for="verifyPassword" class="form-label">Verify Password</label>
<input id="verifyPassword" class="form-control" name="verify_password" type="password" value>
<button class="btn btn-primary mt-2">Change Password</button>
</form>
</div>
</div>
<script>
const passwordField = document.getElementById("password");
const verifPasswordField = document.getElementById("verifyPassword")
document.getElementById("nameForm").addEventListener("htmx:afterRequest", (ev) => {
if (ev.detail.successful) {
alert("Successfully changed username")
}
})
document.getElementById("emailForm").addEventListener("htmx:afterRequest", (ev) => {
if (ev.detail.successful) {
alert("Successfully changed password")
}
})
document.getElementById("passwordForm").addEventListener("htmx:afterRequest", (ev) => {
if (ev.detail.successful) {
passwordField.value = ""
verifPasswordField.value = ""
alert("Successfully changed password")
}
})
</script>
}