commit 23b80bc084d9122ac2c2984b6d6d7e2e05d45f21 Author: Evann Regnault Date: Thu Mar 13 08:25:39 2025 +0100 [First Commit] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfb9468 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.db +.idea +build/ + +templates/*.go \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a86ea3f --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +all: + templ generate + mkdir -p build + cd build + go build -o build/sonarqube-badges . \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..b562227 --- /dev/null +++ b/config/config.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..71d9d19 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c17a69f --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..dc9e542 --- /dev/null +++ b/main.go @@ -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) +} diff --git a/router/api/login.go b/router/api/login.go new file mode 100644 index 0000000..e74ca5b --- /dev/null +++ b/router/api/login.go @@ -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") +} diff --git a/router/api/projectBadge.go b/router/api/projectBadge.go new file mode 100644 index 0000000..38b87ec --- /dev/null +++ b/router/api/projectBadge.go @@ -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") +} diff --git a/router/middlewares/checkAuth.go b/router/middlewares/checkAuth.go new file mode 100644 index 0000000..f173632 --- /dev/null +++ b/router/middlewares/checkAuth.go @@ -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) + }) +} diff --git a/router/server.go b/router/server.go new file mode 100644 index 0000000..2e3f5aa --- /dev/null +++ b/router/server.go @@ -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() +} diff --git a/router/views/index.go b/router/views/index.go new file mode 100644 index 0000000..352bd3f --- /dev/null +++ b/router/views/index.go @@ -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") +} diff --git a/router/views/login.go b/router/views/login.go new file mode 100644 index 0000000..619278d --- /dev/null +++ b/router/views/login.go @@ -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") +} diff --git a/security/aes.go b/security/aes.go new file mode 100644 index 0000000..39ab217 --- /dev/null +++ b/security/aes.go @@ -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) +} diff --git a/security/jwt.go b/security/jwt.go new file mode 100644 index 0000000..67175bf --- /dev/null +++ b/security/jwt.go @@ -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 +} diff --git a/store/db.go b/store/db.go new file mode 100644 index 0000000..485337a --- /dev/null +++ b/store/db.go @@ -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) +} diff --git a/templates/index.templ b/templates/index.templ new file mode 100644 index 0000000..6f69903 --- /dev/null +++ b/templates/index.templ @@ -0,0 +1,5 @@ +package templates + +templ Index() { +
+} \ No newline at end of file diff --git a/templates/layout.templ b/templates/layout.templ new file mode 100644 index 0000000..4c76278 --- /dev/null +++ b/templates/layout.templ @@ -0,0 +1,29 @@ +package templates + +templ head(title string) { + + { title } + + + +} + +templ header() { + +} + +templ Layout(component templ.Component, title string) { + + @head(title) + + @header() +
+ @component +
+ + + + +} \ No newline at end of file diff --git a/templates/login.templ b/templates/login.templ new file mode 100644 index 0000000..9df8033 --- /dev/null +++ b/templates/login.templ @@ -0,0 +1,26 @@ +package templates + +templ Login() { +
+
+
+
+ + +
+
+ + +
+ +
+ + +
+} \ No newline at end of file