diff --git a/Dockerfile-qa b/Dockerfile-qa index 7f62a787..264f3c23 100644 --- a/Dockerfile-qa +++ b/Dockerfile-qa @@ -20,6 +20,7 @@ COPY settings settings/ COPY libfic ./libfic/ COPY --from=nodebuild /ui ./qa/ui COPY qa ./qa/ +COPY admin ./admin/ RUN go get -d -v ./qa && \ go build -v -buildvcs=false -o qa/qa ./qa diff --git a/qa/api/admin.go b/qa/api/admin.go new file mode 100644 index 00000000..139f8da0 --- /dev/null +++ b/qa/api/admin.go @@ -0,0 +1,97 @@ +package api + +import ( + "encoding/json" + "flag" + "io" + "net/http" + "net/url" + "path" + + "github.com/gin-gonic/gin" +) + +var adminLink string + +func init() { + flag.StringVar(&adminLink, "admin-link", adminLink, "URL to admin interface, to use its features as replacement") +} + +func fwdAdmin(method, urlpath string, body io.Reader, out interface{}) error { + u, err := url.Parse(adminLink) + if err != nil { + return err + } + + var user, pass string + if u.User != nil { + user = u.User.Username() + pass, _ = u.User.Password() + u.User = nil + } + + u.Path = path.Join(u.Path, urlpath) + + r, err := http.NewRequest(method, u.String(), body) + if err != nil { + return err + } + + if len(user) != 0 || len(pass) != 0 { + r.SetBasicAuth(user, pass) + } + + resp, err := http.DefaultClient.Do(r) + if err != nil { + return err + } + defer resp.Body.Close() + + dec := json.NewDecoder(resp.Body) + return dec.Decode(&out) +} + +func fwdAdminRequest(c *gin.Context, urlpath string) { + u, err := url.Parse(adminLink) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + var user, pass string + if u.User != nil { + user = u.User.Username() + pass, _ = u.User.Password() + u.User = nil + } + + if len(urlpath) > 0 { + u.Path = path.Join(u.Path, urlpath) + } else { + u.Path = path.Join(u.Path, c.Request.URL.Path) + } + + r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + if len(user) != 0 || len(pass) != 0 { + r.SetBasicAuth(user, pass) + } + + resp, err := http.DefaultClient.Do(r) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadGateway, gin.H{"errmsg": err.Error()}) + return + } + defer resp.Body.Close() + + headers := map[string]string{} + for key := range resp.Header { + headers[key] = resp.Header.Get(key) + } + + c.DataFromReader(resp.StatusCode, resp.ContentLength, resp.Header.Get("Content-Type"), resp.Body, headers) +} diff --git a/qa/api/exercice.go b/qa/api/exercice.go index 1820a1cc..bd1c5040 100644 --- a/qa/api/exercice.go +++ b/qa/api/exercice.go @@ -63,5 +63,10 @@ func listExercices(c *gin.Context) { } func showExercice(c *gin.Context) { - c.JSON(http.StatusOK, c.MustGet("exercice")) + if adminLink == "" { + c.JSON(http.StatusOK, c.MustGet("exercice")) + return + } + + fwdAdminRequest(c, fmt.Sprintf("/api/exercices/%s", string(c.Param("eid")))) } diff --git a/qa/api/gitlab.go b/qa/api/gitlab.go new file mode 100644 index 00000000..25faaec1 --- /dev/null +++ b/qa/api/gitlab.go @@ -0,0 +1,293 @@ +package api + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" + + adminapi "srs.epita.fr/fic-server/admin/api" + "srs.epita.fr/fic-server/libfic" +) + +var ( + gitlabBaseURL = "https://gitlab.cri.epita.fr" + gitlabClientID = "" + gitlabSecret = "" + gitlaboauth2Config *oauth2.Config + oidcRedirectURL = "https://fic.srs.epita.fr" +) + +func init() { + flag.StringVar(&oidcRedirectURL, "oidc-redirect", oidcRedirectURL, "Base URL for the redirect after connection") + flag.StringVar(&gitlabBaseURL, "gitlab-baseurl", gitlabBaseURL, "Base URL of the Gitlab instance") + flag.StringVar(&gitlabClientID, "gitlab-clientid", gitlabClientID, "ClientID for GitLab's OIDC") + flag.StringVar(&gitlabSecret, "gitlab-secret", gitlabSecret, "Secret for GitLab's OIDC") +} + +func InitializeGitLabOIDC(baseURL string) { + if gitlabClientID != "" && gitlabSecret != "" { + gitlaboauth2Config = &oauth2.Config{ + ClientID: gitlabClientID, + ClientSecret: gitlabSecret, + RedirectURL: oidcRedirectURL + baseURL + "/callback/gitlab/complete", + + // Discovery returns the OAuth2 endpoints. + Endpoint: oauth2.Endpoint{ + AuthURL: gitlabBaseURL + "/oauth/authorize", + TokenURL: gitlabBaseURL + "/oauth/token", + }, + + // "openid" is a required scope for OpenID Connect flows. + Scopes: []string{"api", "email"}, + } + } +} + +func declareGitlabRoutes(router *gin.RouterGroup, authrouter *gin.RouterGroup) { + if gitlaboauth2Config != nil { + router.GET("/auth/gitlab", redirectOAuth_GitLab) + router.GET("/callback/gitlab/complete", GitLab_OAuth_complete) + + tokenRoutes := authrouter.Group("") + tokenRoutes.Use(gitlabHandler) + + tokenRoutes.GET("/gitlab/token", GitLab_GetMyToken) + } +} + +func declareGitlabExportRoutes(router *gin.RouterGroup) { + if gitlaboauth2Config != nil { + tokenRoutes := router.Group("") + tokenRoutes.Use(gitlabHandler) + + if adminLink == "" { + log.Println("-admin-link should be defined to be able to export to GitLab!") + } else { + tokenRoutes.POST("/gitlab_export", GitLab_ExportQA) + } + } +} + +func gitlabHandler(c *gin.Context) { + session := sessions.Default(c) + + var oauth2Token *oauth2.Token + if tok, ok := session.Get("gitlab-token").([]byte); ok { + err := json.Unmarshal(tok, &oauth2Token) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + } else { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"errmsg": "No token defined"}) + return + } + + err := prepareOAuth2Token(c.Request.Context(), oauth2Token) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.Set("gitlab-token", oauth2Token) + + c.Next() +} + +func prepareOAuth2Token(ctx context.Context, tk *oauth2.Token) error { + tsource := oauth2.ReuseTokenSource(tk, gitlaboauth2Config.TokenSource(ctx, tk)) + + _, err := tsource.Token() + if err != nil { + log.Println("Unable to regenerate GitLab token:", err) + } + return err +} + +func redirectOAuth_GitLab(c *gin.Context) { + session := sessions.Default(c) + + // Save next parameter + if len(c.Request.URL.Query().Get("next")) > 0 { + session.Set("gitlab-oidc-source", c.Request.URL.Query().Get("next")) + } + + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + session.Set("oidc-state", b) + session.Save() + + c.Redirect(http.StatusFound, gitlaboauth2Config.AuthCodeURL(hex.EncodeToString(b))) +} + +func GitLab_OAuth_complete(c *gin.Context) { + session := sessions.Default(c) + + state, err := hex.DecodeString(c.Request.URL.Query().Get("state")) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + if session_state, ok := session.Get("oidc-state").([]byte); !ok { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "No OIDC session in progress."}) + return + } else if !bytes.Equal(state, session_state) { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad session state."}) + return + } + + oauth2Token, err := gitlaboauth2Config.Exchange(c.Request.Context(), c.Request.URL.Query().Get("code")) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Failed to exchange token: " + err.Error()}) + return + } + + jsonToken, err := json.Marshal(oauth2Token) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + } + + session.Set("gitlab-token", jsonToken) + prepareOAuth2Token(context.Background(), oauth2Token) + session.Save() + log.Println("New GitLab OAuth2 session opened") + + baseURL := c.MustGet("baseurl").(string) + + if source, ok := session.Get("gitlab-oidc-source").(string); ok { + session.Delete("gitlab-oidc-source") + c.Redirect(http.StatusFound, baseURL+source) + } else { + c.Redirect(http.StatusFound, baseURL) + } +} + +func GitLab_GetMyToken(c *gin.Context) { + oauth2Token := c.MustGet("gitlab-token").(*oauth2.Token) + + c.JSON(http.StatusOK, oauth2Token) +} + +type GitLabIssue struct { + Title string + Description string +} + +func gitlab_newIssue(ctx context.Context, token *oauth2.Token, exerciceid string, issue *GitLabIssue) error { + client := gitlaboauth2Config.Client(ctx, token) + + params := url.Values{} + params.Set("title", "[QA] "+issue.Title) + params.Set("description", issue.Description) + + enc, err := json.Marshal(issue) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", gitlabBaseURL+"/api/v4/projects/"+url.QueryEscape(exerciceid)+"/issues?"+params.Encode(), bytes.NewBuffer(enc)) + if err != nil { + return err + } + + req.Header.Add("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + str, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Bad status code from the API: %d %s", resp.StatusCode, string(str)) + } + + return nil +} + +func GitLab_getExerciceId(exercice *fic.Exercice) (string, error) { + // Get the forge link + var adminEx adminapi.Exercice + err := fwdAdmin("GET", fmt.Sprintf("/api/exercices/%d", exercice.Id), nil, &adminEx) + if err != nil { + return "", err + } + + forgelink, err := url.Parse(adminEx.ForgeLink) + if err != nil { + return "", fmt.Errorf("forge_link not found") + } + forgelink.Path = forgelink.Path[:strings.Index(forgelink.Path, "/-/tree/")] + return strings.TrimPrefix(forgelink.Path, "/"), nil +} + +func GitLab_ExportQA(c *gin.Context) { + query := c.MustGet("qa").(*fic.QAQuery) + exercice, err := fic.GetExercice(query.IdExercice) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + gitlabid, err := GitLab_getExerciceId(exercice) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + comments, err := query.GetComments() + if err != nil { + log.Println("Unable to GetComments: ", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to list comments: %s", err.Error())}) + return + } + + description := "<" + path.Join(oidcRedirectURL, "exercices", fmt.Sprintf("%d", exercice.Id), fmt.Sprintf("%d", query.Id)) + ">" + + if len(comments) > 0 { + for i, comment := range comments { + if i > 0 { + description += "\n\n---" + } + description += fmt.Sprintf("\n\n%s\n\nPar %s, le %s", comment.Content, comment.User, comment.Date.Format(time.UnixDate)) + } + } + + // Format the issue + issue := GitLabIssue{ + Title: query.Subject, + Description: description, + } + + // Create the issue on GitLab + oauth2Token := c.MustGet("gitlab-token").(*oauth2.Token) + err = gitlab_newIssue(c.Request.Context(), oauth2Token, gitlabid, &issue) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, gitlabid) +} diff --git a/qa/api/qa.go b/qa/api/qa.go index 82230635..d0cfd52c 100644 --- a/qa/api/qa.go +++ b/qa/api/qa.go @@ -27,6 +27,8 @@ func declareQARoutes(router *gin.RouterGroup) { qaRoutes.GET("comments", getQAComments) qaRoutes.POST("comments", createQAComment) + declareGitlabExportRoutes(qaRoutes) + commentsRoutes := qaRoutes.Group("comments/:cid") commentsRoutes.Use(qaCommentHandler) commentsRoutes.DELETE("", deleteQAComment) diff --git a/qa/api/router.go b/qa/api/router.go index a59013b8..b516bbf1 100644 --- a/qa/api/router.go +++ b/qa/api/router.go @@ -15,6 +15,7 @@ func DeclareRoutes(router *gin.RouterGroup) { declareThemesRoutes(apiRoutes) declareTodoRoutes(apiRoutes) declareVersionRoutes(apiRoutes) + declareGitlabRoutes(router, apiRoutes) apiManagerRoutes := router.Group("/api") apiManagerRoutes.Use(authMiddleware(func(ficteam string, teamid int64, c *gin.Context) bool { diff --git a/qa/api/version.go b/qa/api/version.go index 75652302..31775b62 100644 --- a/qa/api/version.go +++ b/qa/api/version.go @@ -24,7 +24,7 @@ func showVersion(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "version": 0.2, + "version": 0.3, "auth": map[string]interface{}{ "name": ficteam, "id_team": teamid, diff --git a/qa/app.go b/qa/app.go index 1c6823cf..1d45b211 100644 --- a/qa/app.go +++ b/qa/app.go @@ -22,8 +22,13 @@ func NewApp(baseURL string) App { gin.ForceConsoleColor() router := gin.Default() + api.InitializeGitLabOIDC(baseURL) + store := memstore.NewStore([]byte("secret")) router.Use(sessions.Sessions("qa-session", store)) + router.Use(func(c *gin.Context) { + c.Set("baseurl", baseURL) + }) api.DeclareRoutes(router.Group("")) diff --git a/qa/ui/src/lib/components/ExerciceHeader.svelte b/qa/ui/src/lib/components/ExerciceHeader.svelte index 9ead6aa6..24976f7a 100644 --- a/qa/ui/src/lib/components/ExerciceHeader.svelte +++ b/qa/ui/src/lib/components/ExerciceHeader.svelte @@ -48,5 +48,8 @@ {$themesIdx[exercice.id_theme].name} Site du challenge + {#if exercice.forge_link} + GitLab + {/if} {/if} diff --git a/qa/ui/src/lib/components/Header.svelte b/qa/ui/src/lib/components/Header.svelte index aebc0e7f..a2751a6f 100644 --- a/qa/ui/src/lib/components/Header.svelte +++ b/qa/ui/src/lib/components/Header.svelte @@ -33,6 +33,17 @@ activemenu = ""; } } + + async function rungitlab() { + const res = await fetch('api/gitlab/token'); + if (res.status === 200) { + return res.json(); + } else { + throw new Error((await res.json()).errmsg); + } + } + + const gitlab = rungitlab(); @@ -90,6 +101,17 @@ {/if}