[First Commit]

This commit is contained in:
Evann Regnault 2025-03-13 08:25:39 +01:00
commit 23b80bc084
18 changed files with 506 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
*.db
.idea
build/
templates/*.go

5
Makefile Normal file
View file

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

23
config/config.go Normal file
View file

@ -0,0 +1,23 @@
package config
import (
"context"
"github.com/kelseyhightower/envconfig"
)
type Config struct {
SqHost string `envconfig:"SQ_HOST"`
AppPassword string `envconfig:"APP_PASSWORD"`
Secret string `envconfig:"SECRET"`
DataPath string `envconfig:"DATA_PATH"`
}
func ProcessConfiguration(ctx context.Context) context.Context {
var cfg Config
err := envconfig.Process("", &cfg)
if err != nil {
panic("failed to process config")
}
return context.WithValue(ctx, "config", cfg)
}

23
go.mod Normal file
View file

@ -0,0 +1,23 @@
module sonarqube-badge
go 1.24
require (
github.com/a-h/templ v0.3.833
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
github.com/kelseyhightower/envconfig v1.4.0
github.com/syronz/goAES v0.4.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
require (
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
golang.org/x/text v0.23.0 // indirect
)

30
go.sum Normal file
View file

@ -0,0 +1,30 @@
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
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/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
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/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/syronz/goAES v0.4.0 h1:amXL8f0fSx1I+k2EPebq19hMkXSdcmeL1bqJzFzJZp4=
github.com/syronz/goAES v0.4.0/go.mod h1:5bXPMfiPVfXQWqZ5UFuPlQW1Lkx4fv3dJ8IoBZPXQgo=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=

16
main.go Normal file
View file

@ -0,0 +1,16 @@
package main
import (
"context"
"sonarqube-badge/config"
"sonarqube-badge/router"
"sonarqube-badge/store"
)
func main() {
ctx := context.Background()
ctx = config.ProcessConfiguration(ctx)
ctx = store.CreateDatabase(ctx)
router.StartServer(ctx)
}

39
router/api/login.go Normal file
View file

@ -0,0 +1,39 @@
package api
import (
"github.com/gorilla/mux"
"net/http"
"sonarqube-badge/security"
)
func postLogin(res http.ResponseWriter, req *http.Request) {
if err := req.ParseForm(); err != nil {
http.Error(res, err.Error(), http.StatusBadRequest)
}
email := req.Form.Get("email")
password := req.Form.Get("password")
if email == "1@example.com" && password == "hello" {
token, err := security.CreateToken(email)
if err != nil {
http.Error(res, err.Error(), http.StatusInternalServerError)
return
}
cookie := http.Cookie{
Name: "jwt-token",
Value: token,
Path: "/",
Secure: true,
HttpOnly: true,
}
http.SetCookie(res, &cookie)
} else {
res.WriteHeader(http.StatusUnauthorized)
res.Write([]byte("Wrong email or password"))
}
}
func LoginRouter(r *mux.Router) {
r.HandleFunc("", postLogin).Methods("POST")
}

View file

@ -0,0 +1,99 @@
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")
}

View file

@ -0,0 +1,43 @@
package middlewares
import (
"context"
"net/http"
"sonarqube-badge/config"
"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 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenCookie, err := r.Cookie("jwt-token")
if err != nil || tokenCookie == nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, err = security.VerifyToken(tokenCookie.Value)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
h.ServeHTTP(w, r)
})
}

37
router/server.go Normal file
View file

@ -0,0 +1,37 @@
package router
import (
"context"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"net"
"net/http"
"sonarqube-badge/router/api"
"sonarqube-badge/router/views"
)
func StartServer(ctx context.Context) {
r := mux.NewRouter()
// API ROUTES
api.ProjectBadgeRouter(r.PathPrefix("/api/project/badge").Subrouter())
api.LoginRouter(r.PathPrefix("/api/login").Subrouter())
// VIEW ROUTES
views.IndexRouter(r)
views.LoginRouter(r)
credentials := handlers.AllowCredentials()
methods := handlers.AllowedMethods([]string{"POST", "GET", "OPTIONS"})
ttl := handlers.MaxAge(3600)
server := http.Server{
Addr: ":8080",
BaseContext: func(listener net.Listener) context.Context {
return ctx
},
Handler: handlers.CORS(credentials, methods, ttl)(r),
}
server.ListenAndServe()
}

18
router/views/index.go Normal file
View file

@ -0,0 +1,18 @@
package views
import (
"github.com/gorilla/mux"
"net/http"
"sonarqube-badge/router/middlewares"
"sonarqube-badge/templates"
)
func getIndex(res http.ResponseWriter, req *http.Request) {
templates.Layout(templates.Index(), "Index").Render(req.Context(), res)
}
func IndexRouter(r *mux.Router) {
subrouter := r.PathPrefix("").Subrouter()
subrouter.Use(middlewares.CheckJwtToken)
subrouter.HandleFunc("/", getIndex).Methods("GET")
}

15
router/views/login.go Normal file
View file

@ -0,0 +1,15 @@
package views
import (
"github.com/gorilla/mux"
"net/http"
"sonarqube-badge/templates"
)
func getLogin(res http.ResponseWriter, req *http.Request) {
templates.Layout(templates.Login(), "Login").Render(req.Context(), res)
}
func LoginRouter(r *mux.Router) {
r.HandleFunc("/login", getLogin).Methods("GET")
}

18
security/aes.go Normal file
View file

@ -0,0 +1,18 @@
package security
import (
"crypto/sha256"
goaes "github.com/syronz/goAES"
)
func EncryptAES(password string, plaintext string) string {
iv := sha256.Sum256([]byte(password))
aes, _ := goaes.New().Key(password).IV(string(iv[:16])).Build()
return aes.Encrypt(plaintext)
}
func DecryptAES(password string, ct string) string {
iv := sha256.Sum256([]byte(password))
aes, _ := goaes.New().Key(password).IV(string(iv[:16])).Build()
return aes.Decrypt(ct)
}

37
security/jwt.go Normal file
View file

@ -0,0 +1,37 @@
package security
import (
"errors"
"github.com/golang-jwt/jwt/v5"
"time"
)
type JwtClaims struct {
}
func CreateToken(username string) (string, error) {
t := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{
"username": username,
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
return t.SignedString([]byte("secret"))
}
func VerifyToken(jwtString string) (*jwt.Token, error) {
parse, err := jwt.Parse(jwtString, func(token *jwt.Token) (interface{}, error) {
return []byte("secret"), nil
})
if err != nil {
return nil, err
}
if !parse.Valid {
return nil, errors.New("invalid token")
}
if time.Unix(int64(parse.Claims.(jwt.MapClaims)["exp"].(float64)), 0).Before(time.Now()) {
return nil, errors.New("token is expired")
}
return parse, nil
}

38
store/db.go Normal file
View file

@ -0,0 +1,38 @@
package store
import (
"context"
"fmt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sonarqube-badge/config"
)
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 {
cfg := ctx.Value("config").(config.Config)
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("%s/sqbadge.db", cfg.DataPath)), &gorm.Config{})
if err != nil {
panic("failed to open database")
}
err = db.AutoMigrate(&SQProject{})
if err != nil {
panic("failed to migrate database")
}
return context.WithValue(ctx, "db", db)
}

5
templates/index.templ Normal file
View file

@ -0,0 +1,5 @@
package templates
templ Index() {
<form class></form>
}

29
templates/layout.templ Normal file
View file

@ -0,0 +1,29 @@
package templates
templ head(title string) {
<head>
<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">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</head>
}
templ header() {
<nav class="navbar navbar-expand-lg navbar-dark bg-dark p-2">
<a class="navbar-brand">Sonarqube Badges</a>
</nav>
}
templ Layout(component templ.Component, title string) {
<html lang="en">
@head(title)
<body hx-ext="response-targets">
@header()
<div class="container-fluid m-4">
@component
</div>
</body>
<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>
</html>
}

26
templates/login.templ Normal file
View file

@ -0,0 +1,26 @@
package templates
templ Login() {
<div class="container">
<form hx-post="/api/login" hx-target-error="#loginError" id="loginForm">
<div hx-trigger="changed" id="loginError"></div>
<div class="mb-3">
<label for="email" class="form-label">Email Address</label>
<input name="email" class="form-control" type="email" id="email">
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input name="password" class="form-control" type="password" id="password">
</div>
<button class="btn btn-primary">Connect</button>
</form>
<script type="application/javascript">
document.getElementById('loginForm').addEventListener('htmx:afterRequest', function (ev) {
if (ev.detail.successful) {
window.location.href = '/'
}
});
</script>
</div>
}