diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5ee2aac --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.env +.gitignore +.idea +.dockerignore +*.db + +build/ +tmp/ +templates/*.go \ No newline at end of file diff --git a/.gitignore b/.gitignore index cfb9468..c936118 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ *.db +.env .idea + +tmp/ build/ templates/*.go \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9a039f4 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/Makefile b/Makefile deleted file mode 100644 index a86ea3f..0000000 --- a/Makefile +++ /dev/null @@ -1,5 +0,0 @@ -all: - templ generate - mkdir -p build - cd build - go build -o build/sonarqube-badges . \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..57003e8 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# SonarQube Badges + + +## 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) \ No newline at end of file diff --git a/config/config.go b/config/config.go index b562227..d28d23d 100644 --- a/config/config.go +++ b/config/config.go @@ -6,10 +6,10 @@ import ( ) type Config struct { - SqHost string `envconfig:"SQ_HOST"` - AppPassword string `envconfig:"APP_PASSWORD"` - Secret string `envconfig:"SECRET"` - DataPath string `envconfig:"DATA_PATH"` + SqHost string `envconfig:"SQ_HOST"` + Secret string `envconfig:"SECRET"` + DataPath string `envconfig:"DATA_PATH"` + Port int `envconfig:"PORT"` } func ProcessConfiguration(ctx context.Context) context.Context { diff --git a/go.mod b/go.mod index 71d9d19..9a8e628 100644 --- a/go.mod +++ b/go.mod @@ -14,10 +14,10 @@ require ( ) 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/joho/godotenv v1.5.1 // 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 index c17a69f..d025d3f 100644 --- a/go.sum +++ b/go.sum @@ -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/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/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/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= 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/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 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/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= diff --git a/main.go b/main.go index dc9e542..a02e9da 100644 --- a/main.go +++ b/main.go @@ -2,15 +2,40 @@ package main import ( "context" + "github.com/joho/godotenv" + "os" + "os/signal" "sonarqube-badge/config" "sonarqube-badge/router" "sonarqube-badge/store" + "syscall" ) 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 = config.ProcessConfiguration(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) } diff --git a/router/api/login.go b/router/api/login.go index e74ca5b..04e043d 100644 --- a/router/api/login.go +++ b/router/api/login.go @@ -3,7 +3,7 @@ package api import ( "github.com/gorilla/mux" "net/http" - "sonarqube-badge/security" + "sonarqube-badge/router/utils" ) 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") password := req.Form.Get("password") - if email == "1@example.com" && password == "hello" { - token, err := security.CreateToken(email) + ctx := req.Context() + + if utils.UserExists(ctx, email, password) { + user := utils.GetUser(ctx, email) + cookie, err := utils.CreateJWTCookie(user, req) if err != nil { - http.Error(res, err.Error(), http.StatusInternalServerError) + res.WriteHeader(http.StatusInternalServerError) return } - cookie := http.Cookie{ - Name: "jwt-token", - Value: token, - Path: "/", - Secure: true, - HttpOnly: true, - } - http.SetCookie(res, &cookie) + http.SetCookie(res, cookie) } else { res.WriteHeader(http.StatusUnauthorized) res.Write([]byte("Wrong email or password")) diff --git a/router/api/project.go b/router/api/project.go new file mode 100644 index 0000000..c9c5796 --- /dev/null +++ b/router/api/project.go @@ -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) +} diff --git a/router/api/projectBadge.go b/router/api/projectBadge.go deleted file mode 100644 index 38b87ec..0000000 --- a/router/api/projectBadge.go +++ /dev/null @@ -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") -} diff --git a/router/api/user.go b/router/api/user.go new file mode 100644 index 0000000..e918055 --- /dev/null +++ b/router/api/user.go @@ -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") +} diff --git a/router/middlewares/checkAuth.go b/router/middlewares/checkAuth.go index f173632..ec8ea78 100644 --- a/router/middlewares/checkAuth.go +++ b/router/middlewares/checkAuth.go @@ -1,40 +1,29 @@ package middlewares import ( - "context" "net/http" - "sonarqube-badge/config" - "sonarqube-badge/security" - "strings" + "sonarqube-badge/security/jwt" ) -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) + if r.Method == "GET" { + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + } else { + w.WriteHeader(http.StatusUnauthorized) + } return } - _, err = security.VerifyToken(tokenCookie.Value) + _, err = jwt.VerifyToken(tokenCookie.Value, r.Context()) if err != nil { - w.WriteHeader(http.StatusUnauthorized) + if r.Method == "GET" { + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + } else { + w.WriteHeader(http.StatusUnauthorized) + } return } diff --git a/router/server.go b/router/server.go index 2e3f5aa..79c0a54 100644 --- a/router/server.go +++ b/router/server.go @@ -2,10 +2,12 @@ package router import ( "context" + "fmt" "github.com/gorilla/handlers" "github.com/gorilla/mux" "net" "net/http" + "sonarqube-badge/config" "sonarqube-badge/router/api" "sonarqube-badge/router/views" ) @@ -14,24 +16,38 @@ func StartServer(ctx context.Context) { r := mux.NewRouter() // 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.UserRouter(r.PathPrefix("/api/user").Subrouter()) // VIEW ROUTES views.IndexRouter(r) views.LoginRouter(r) + views.ProjectRouter(r) + views.BadgeRouter(r) + views.UserRouter(r) credentials := handlers.AllowCredentials() - methods := handlers.AllowedMethods([]string{"POST", "GET", "OPTIONS"}) + methods := handlers.AllowedMethods([]string{"POST", "GET", "DELETE"}) 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{ - Addr: ":8080", + Addr: fmt.Sprintf(":%d", port), BaseContext: func(listener net.Listener) context.Context { return ctx }, Handler: handlers.CORS(credentials, methods, ttl)(r), } - server.ListenAndServe() + err := server.ListenAndServe() + if err != nil { + panic(err) + } } diff --git a/router/utils/jwtUtils.go b/router/utils/jwtUtils.go new file mode 100644 index 0000000..195f156 --- /dev/null +++ b/router/utils/jwtUtils.go @@ -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 +} diff --git a/router/utils/userUtils.go b/router/utils/userUtils.go new file mode 100644 index 0000000..3000fa0 --- /dev/null +++ b/router/utils/userUtils.go @@ -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 +} diff --git a/router/utils/verify.go b/router/utils/verify.go new file mode 100644 index 0000000..0654ab7 --- /dev/null +++ b/router/utils/verify.go @@ -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 +} diff --git a/router/views/badges.go b/router/views/badges.go new file mode 100644 index 0000000..80e3e28 --- /dev/null +++ b/router/views/badges.go @@ -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") +} diff --git a/router/views/index.go b/router/views/index.go index 352bd3f..183fc1f 100644 --- a/router/views/index.go +++ b/router/views/index.go @@ -4,11 +4,13 @@ import ( "github.com/gorilla/mux" "net/http" "sonarqube-badge/router/middlewares" + "sonarqube-badge/router/utils" "sonarqube-badge/templates" ) 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) { diff --git a/router/views/login.go b/router/views/login.go index 619278d..a29f78e 100644 --- a/router/views/login.go +++ b/router/views/login.go @@ -3,13 +3,30 @@ package views import ( "github.com/gorilla/mux" "net/http" + "sonarqube-badge/router/utils" "sonarqube-badge/templates" ) 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) { r.HandleFunc("/login", getLogin).Methods("GET") + r.HandleFunc("/disconnect", getDisconnect).Methods("GET") } diff --git a/router/views/project.go b/router/views/project.go new file mode 100644 index 0000000..0c93893 --- /dev/null +++ b/router/views/project.go @@ -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") +} diff --git a/router/views/user.go b/router/views/user.go new file mode 100644 index 0000000..f36e0b6 --- /dev/null +++ b/router/views/user.go @@ -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") +} diff --git a/security/aes.go b/security/aes/aes.go similarity index 96% rename from security/aes.go rename to security/aes/aes.go index 39ab217..0ecfc4b 100644 --- a/security/aes.go +++ b/security/aes/aes.go @@ -1,4 +1,4 @@ -package security +package aes import ( "crypto/sha256" diff --git a/security/jwt.go b/security/jwt/jwt.go similarity index 54% rename from security/jwt.go rename to security/jwt/jwt.go index 67175bf..188e0cb 100644 --- a/security/jwt.go +++ b/security/jwt/jwt.go @@ -1,26 +1,29 @@ -package security +package jwt import ( + "context" "errors" "github.com/golang-jwt/jwt/v5" + "sonarqube-badge/config" + "sonarqube-badge/store" "time" ) -type JwtClaims struct { -} - -func CreateToken(username string) (string, error) { +func CreateToken(user store.User, ctx context.Context) (string, error) { t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "username": username, + "username": user.Username, + "email": user.Email, "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) { - return []byte("secret"), nil + return []byte(secret), nil }) if err != nil { return nil, err diff --git a/store/db.go b/store/db.go index 485337a..935f703 100644 --- a/store/db.go +++ b/store/db.go @@ -2,26 +2,16 @@ package store import ( "context" + "crypto/sha1" "fmt" "gorm.io/driver/sqlite" "gorm.io/gorm" + "os" "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 { cfg := ctx.Value("config").(config.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") } - err = db.AutoMigrate(&SQProject{}) + err = db.AutoMigrate(&Project{}) 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) } + +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) + } +} diff --git a/store/project.go b/store/project.go new file mode 100644 index 0000000..f9a47d3 --- /dev/null +++ b/store/project.go @@ -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{}) +} diff --git a/store/user.go b/store/user.go new file mode 100644 index 0000000..30f1138 --- /dev/null +++ b/store/user.go @@ -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 +} diff --git a/templates/index.templ b/templates/index.templ index 6f69903..455b588 100644 --- a/templates/index.templ +++ b/templates/index.templ @@ -1,5 +1,34 @@ package templates -templ Index() { -
-} \ No newline at end of file +import "github.com/golang-jwt/jwt/v5" + +templ Index(token *jwt.Token) { + {{ + username := "" + if claims, ok := token.Claims.(jwt.MapClaims); ok { + username = claims["username"].(string) + } + }} +