2023-11-25 12:43:41 +00:00
|
|
|
package api
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"context"
|
|
|
|
"crypto/rand"
|
|
|
|
"encoding/hex"
|
|
|
|
"encoding/json"
|
|
|
|
"flag"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"log"
|
|
|
|
"net/http"
|
|
|
|
"net/url"
|
2023-11-25 17:37:35 +00:00
|
|
|
"os"
|
2023-11-25 12:43:41 +00:00
|
|
|
"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() {
|
2023-11-25 17:37:35 +00:00
|
|
|
if v, ok := os.LookupEnv("FIC_OIDC_REDIRECT"); ok {
|
|
|
|
oidcRedirectURL = v
|
|
|
|
}
|
|
|
|
|
2024-09-18 06:39:41 +00:00
|
|
|
if v, ok := os.LookupEnv("FIC_GITLAB_BASEURL"); ok {
|
|
|
|
gitlabBaseURL = v
|
|
|
|
}
|
|
|
|
|
2023-11-25 12:43:41 +00:00
|
|
|
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")
|
2023-11-25 17:37:35 +00:00
|
|
|
flag.StringVar(&gitlabClientID, "gitlab-clientid", os.Getenv("FIC_GITLAB_CLIENT_ID"), "ClientID for GitLab's OIDC")
|
|
|
|
flag.StringVar(&gitlabSecret, "gitlab-secret", os.Getenv("FIC_GITLAB_CLIENT_SECRET"), "Secret for GitLab's OIDC")
|
2023-11-25 12:43:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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 {
|
2023-11-26 11:57:04 +00:00
|
|
|
Id int64 `json:"id,omitempty"`
|
|
|
|
IId int64 `json:"iid,omitempty"`
|
|
|
|
ProjectId int64 `json:"project_id,omitempty"`
|
|
|
|
Title string `json:"title"`
|
|
|
|
Description string `json:"description"`
|
|
|
|
WebURL string `json:"web_url"`
|
2023-11-25 12:43:41 +00:00
|
|
|
}
|
|
|
|
|
2023-11-26 11:57:04 +00:00
|
|
|
func gitlab_newIssue(ctx context.Context, token *oauth2.Token, exerciceid string, issue *GitLabIssue) (*GitLabIssue, error) {
|
2023-11-25 12:43:41 +00:00
|
|
|
client := gitlaboauth2Config.Client(ctx, token)
|
|
|
|
|
|
|
|
enc, err := json.Marshal(issue)
|
|
|
|
if err != nil {
|
2023-11-26 11:57:04 +00:00
|
|
|
return nil, err
|
2023-11-25 12:43:41 +00:00
|
|
|
}
|
|
|
|
|
2023-11-26 11:57:04 +00:00
|
|
|
req, err := http.NewRequest("POST", gitlabBaseURL+"/api/v4/projects/"+url.QueryEscape(exerciceid)+"/issues", bytes.NewBuffer(enc))
|
2023-11-25 12:43:41 +00:00
|
|
|
if err != nil {
|
2023-11-26 11:57:04 +00:00
|
|
|
return nil, err
|
2023-11-25 12:43:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
req.Header.Add("Content-Type", "application/json")
|
|
|
|
|
|
|
|
resp, err := client.Do(req)
|
|
|
|
if err != nil {
|
2023-11-26 11:57:04 +00:00
|
|
|
return nil, err
|
2023-11-25 12:43:41 +00:00
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
|
|
str, _ := io.ReadAll(resp.Body)
|
2023-11-26 11:57:04 +00:00
|
|
|
return nil, fmt.Errorf("Bad status code from the API: %d %s", resp.StatusCode, string(str))
|
|
|
|
}
|
|
|
|
|
|
|
|
var issueCreated GitLabIssue
|
|
|
|
dec := json.NewDecoder(resp.Body)
|
|
|
|
err = dec.Decode(&issueCreated)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("Unable to decode issue JSON: %s", err.Error())
|
2023-11-25 12:43:41 +00:00
|
|
|
}
|
|
|
|
|
2023-11-26 11:57:04 +00:00
|
|
|
return &issueCreated, nil
|
2023-11-25 12:43:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-11-26 11:57:04 +00:00
|
|
|
description := "<" + oidcRedirectURL + path.Join(path.Clean("/"+c.MustGet("baseurl").(string)), "exercices", fmt.Sprintf("%d", exercice.Id), fmt.Sprintf("%d", query.Id)) + ">"
|
2023-11-25 12:43:41 +00:00
|
|
|
|
|
|
|
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{
|
2023-11-26 11:57:04 +00:00
|
|
|
Title: "[QA] " + query.Subject,
|
2023-11-25 12:43:41 +00:00
|
|
|
Description: description,
|
|
|
|
}
|
|
|
|
|
|
|
|
// Create the issue on GitLab
|
|
|
|
oauth2Token := c.MustGet("gitlab-token").(*oauth2.Token)
|
2023-11-26 11:57:04 +00:00
|
|
|
iid, err := gitlab_newIssue(c.Request.Context(), oauth2Token, gitlabid, &issue)
|
2023-11-25 12:43:41 +00:00
|
|
|
if err != nil {
|
|
|
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-11-26 11:57:04 +00:00
|
|
|
query.Exported = &iid.IId
|
|
|
|
_, err = query.Update()
|
|
|
|
if err != nil {
|
|
|
|
log.Println("Unable to update QAquery in GitLab_ExportQA:", err.Error())
|
|
|
|
}
|
|
|
|
|
|
|
|
c.JSON(http.StatusOK, iid)
|
2023-11-25 12:43:41 +00:00
|
|
|
}
|