Le challenge forensic vous place dans la peau de spécialistes en investigation numérique. Nous mettons à votre disposition une vingtaine de scénarios différents, dans lesquels vous devrez faire les différentes étapes de la caractérisation d’une réponse à incident proposées.
+Chaque scénario met en scène un contexte d’entreprise, ayant découvert récemment qu’elle a été victime d’une cyberattaque. Elle vous demande alors de l’aider à caractériser, afin de mieux comprendre la situation, notamment le mode opératoire de l’adversaire, les impacts de la cyberattaque, le périmètre technique compromis, etc. Il faudra parfois aussi l’éclairer sur les premières étapes de la réaction.
`, + Rules: `Pendant toute la durée du challenge, vous aurez accès à tous les scénarios, mais seulement à la première des 5 étapes. Chaque étape supplémentaire est débloquée lorsque vous validez l’intégralité de l’étape précédente. Toutefois, pour dynamiser le challenge toutes les étapes et tous les scénarios seront débloquées pour la dernière heure du challenge.
+Nous mettons à votre disposition une plateforme sur laquelle vous pourrez obtenir les informations sur le contexte de l’entreprise et, généralement, une série de fichiers qui semblent appropriés pour avancer dans l’investigation.
+La validation d’une étape se fait sur la plateforme, après avoir analysé les informations fournies, en répondant à des questions plus ou moins précises. Il s’agit le plus souvent des mots-clefs que l’on placerait dans un rapport.
+Pour vous débloquer ou accélérer votre investigation, vous pouvez accéder à quelques indices, en échange d’une décote sur votre score d’un certain nombre de points préalablement affichés.
+Chaque équipe dispose d’un compteur de points dans l’intervalle ]-∞;+∞[ (aux détails techniques près), à partir duquel le classement est établi.
+Vous perdez des points en dévoilant des indices, en demandant des propositions de réponses en remplacement de certains champs de texte, ou en essayant un trop grand nombre de fois une réponse.
+Le nombre de points que vous fait perdre un indice dépend habituellement de l’aide qu’il vous apportera et est indiqué avant de le dévoiler, car il peut fluctuer en fonction de l’avancement du challenge.
+Pour chaque champ de texte, vous disposez de 10 tentatives avant de perdre des points (vous perdez les points même si vous ne validez pas l’étape) pour chaque tentative supplémentaire : -0,25 point entre 11 et 20, -0,5 entre 21 et 30, -0,75 entre 31 et 40, …
+La seule manière de gagner des points est de valider une étape d’un scénario dans son intégralité. Le nombre de points gagnés dépend de la difficulté théorique de l’étape ainsi que d’éventuels bonus. Un bonus de 10 % est accordé à la première équipe qui valide une étape. D’autres bonus peuvent ponctuer le challenge, détaillé dans la partie suivante.
+Le classement est établi par équipe, selon le nombre de points récoltés et perdus par tous les membres. En cas d’égalité au score, les équipes sont départagées en fonction de leur ordre d’arrivée à ce score.
+Le challenge forensic est jalonné de plusieurs temps forts durant lesquels certains calculs détaillés dans la partie précédente peuvent être altérés. L’équipe d’animation du challenge vous avertira environ 15 minutes avant le début de la modification.
+Chaque modification se répercute instantanément dans votre interface, attendez simplement qu’elle apparaisse afin d’être certain d’en bénéficier. Un compte à rebours est généralement affiché sur les écrans pour indiquer la fin d’un temps fort. La fin d’application d’un bonus est déterminé par l’heure d’arrivée de votre demande sur nos serveurs.
+Sans y être limité ou assuré, sachez que durant les précédentes éditions du challenge forensic, nous avons par exemple : doublé les points de défis peu tentés, doublé les points de tous les défis pendant 30 minutes, réduit le coût des indices pendant 15 minutes, etc.
+ +Tous les étudiants de la majeure Système, Réseaux et Sécurité de l’ÉPITA, son équipe enseignante ainsi que le commandement de la cyberdéfense vous souhaitent bon courage pour cette nouvelle éditions du challenge !
`, + YourMission: `Vous voici aujourd'hui dans la peau de spécialistes en investigation numérique. Vous avez à votre disposition une vingtaine de scénarios différents dans lesquels vous devrez faire les différentes étapes de la caractérisation d’une réponse à incident.
+Chaque scénario est découpé en 5 grandes étapes de difficulté croissante. Un certain nombre de points est attribué à chaque étape, avec un processus de validation automatique.
+Un classement est établi en temps réel, tenant compte des différents bonus, en fonction du nombre de points de chaque équipe.
`, + }) +} + +func reset(c *gin.Context) { var m map[string]string - if err := json.Unmarshal(body, &m); err != nil { - return nil, err + err := c.ShouldBindJSON(&m) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return } - if t, ok := m["type"]; !ok { - return nil, errors.New("Field type not found") - } else if t == "teams" { - return true, fic.ResetTeams() - } else if t == "challenges" { - return true, fic.ResetExercices() - } else if t == "game" { - return true, fic.ResetGame() - } else { - return nil, errors.New("Unknown reset type") + t, ok := m["type"] + if !ok { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Field type not found"}) } + + switch t { + case "teams": + err = fic.ResetTeams() + case "challenges": + err = fic.ResetExercices() + case "game": + err = fic.ResetGame() + case "annexes": + err = fic.ResetAnnexes() + case "settings": + err = ResetSettings() + case "challengeInfo": + err = ResetChallengeInfo() + default: + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Unknown reset type"}) + return + } + + if err != nil { + log.Printf("Unable to reset (type=%q): %s", t, err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to performe the reset: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, true) } diff --git a/admin/api/sync.go b/admin/api/sync.go new file mode 100644 index 00000000..582c55e0 --- /dev/null +++ b/admin/api/sync.go @@ -0,0 +1,411 @@ +package api + +import ( + "fmt" + "log" + "net/http" + "net/url" + "os" + "path" + "reflect" + "strings" + + "srs.epita.fr/fic-server/admin/generation" + "srs.epita.fr/fic-server/admin/sync" + "srs.epita.fr/fic-server/libfic" + + "github.com/gin-gonic/gin" + "go.uber.org/multierr" +) + +var lastSyncError = "" + +func flatifySyncErrors(errs error) (ret []string) { + for _, err := range multierr.Errors(errs) { + ret = append(ret, err.Error()) + } + return +} + +func declareSyncRoutes(router *gin.RouterGroup) { + apiSyncRoutes := router.Group("/sync") + + // Return the global sync status + apiSyncRoutes.GET("/status", func(c *gin.Context) { + syncMtd := "Disabled" + if sync.GlobalImporter != nil { + syncMtd = sync.GlobalImporter.Kind() + } + + var syncId *string + if sync.GlobalImporter != nil { + syncId = sync.GlobalImporter.Id() + } + + c.JSON(http.StatusOK, gin.H{ + "sync-type": reflect.TypeOf(sync.GlobalImporter).Name(), + "sync-id": syncId, + "sync": syncMtd, + "pullMutex": !sync.OneGitPullStatus(), + "syncMutex": !sync.OneDeepSyncStatus() && !sync.OneThemeDeepSyncStatus(), + "progress": sync.DeepSyncProgress, + "lastError": lastSyncError, + }) + }) + + // Base sync checks if the local directory is in sync with remote one. + apiSyncRoutes.POST("/base", func(c *gin.Context) { + err := sync.GlobalImporter.Sync() + if err != nil { + lastSyncError = err.Error() + c.JSON(http.StatusExpectationFailed, gin.H{"errmsg": err.Error()}) + } else { + lastSyncError = "" + c.JSON(http.StatusOK, true) + } + }) + + // Speedy sync performs a recursive synchronization without importing files. + apiSyncRoutes.POST("/speed", func(c *gin.Context) { + st := sync.SpeedySyncDeep(sync.GlobalImporter) + sync.EditDeepReport(&st, false) + c.JSON(http.StatusOK, st) + }) + + // Deep sync: a fully recursive synchronization (can be limited by theme). + apiSyncRoutes.POST("/deep", func(c *gin.Context) { + r := sync.SyncDeep(sync.GlobalImporter) + lastSyncError = "" + c.JSON(http.StatusOK, r) + }) + + apiSyncRoutes.POST("/local-diff", APIDiffDBWithRemote) + + apiSyncDeepRoutes := apiSyncRoutes.Group("/deep/:thid") + apiSyncDeepRoutes.Use(ThemeHandler) + // Special route to handle standalone exercices + apiSyncRoutes.POST("/deep/0", func(c *gin.Context) { + var st []string + for _, se := range multierr.Errors(sync.SyncThemeDeep(sync.GlobalImporter, &fic.StandaloneExercicesTheme, 0, 250, nil)) { + st = append(st, se.Error()) + } + sync.EditDeepReport(&sync.SyncReport{Exercices: st}, false) + sync.DeepSyncProgress = 255 + lastSyncError = "" + c.JSON(http.StatusOK, st) + }) + apiSyncDeepRoutes.POST("", func(c *gin.Context) { + theme := c.MustGet("theme").(*fic.Theme) + + exceptions := sync.LoadThemeException(sync.GlobalImporter, theme) + + var st []string + for _, se := range multierr.Errors(sync.SyncThemeDeep(sync.GlobalImporter, theme, 0, 250, exceptions)) { + st = append(st, se.Error()) + } + sync.EditDeepReport(&sync.SyncReport{Themes: map[string][]string{theme.Name: st}}, false) + sync.DeepSyncProgress = 255 + lastSyncError = "" + c.JSON(http.StatusOK, st) + }) + + // Auto sync: to use with continuous deployment, in a development env + apiSyncRoutes.POST("/auto/*p", autoSync) + + // Themes + apiSyncRoutes.POST("/fixurlids", fixAllURLIds) + + apiSyncRoutes.POST("/themes", func(c *gin.Context) { + _, errs := sync.SyncThemes(sync.GlobalImporter) + lastSyncError = "" + c.JSON(http.StatusOK, flatifySyncErrors(errs)) + }) + + apiSyncThemesRoutes := apiSyncRoutes.Group("/themes/:thid") + apiSyncThemesRoutes.Use(ThemeHandler) + apiSyncThemesRoutes.POST("/fixurlid", func(c *gin.Context) { + theme := c.MustGet("theme").(*fic.Theme) + if theme.FixURLId() { + v, err := theme.Update() + if err != nil { + log.Println("Unable to UpdateTheme after fixurlid:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs when saving the theme."}) + return + } + + c.JSON(http.StatusOK, v) + } else { + c.AbortWithStatusJSON(http.StatusOK, 0) + } + }) + + // Exercices + declareSyncExercicesRoutes(apiSyncRoutes) + declareSyncExercicesRoutes(apiSyncThemesRoutes) + + // Videos sync imports resolution.mp4 from path stored in database. + apiSyncRoutes.POST("/videos", func(c *gin.Context) { + exercices, err := fic.GetExercices() + if err != nil { + log.Println("Unable to GetExercices:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve exercices list."}) + return + } + + for _, e := range exercices { + if len(e.VideoURI) == 0 || !strings.HasPrefix(e.VideoURI, "$RFILES$/") { + continue + } + + vpath, err := url.PathUnescape(strings.TrimPrefix(e.VideoURI, "$RFILES$/")) + if err != nil { + c.JSON(http.StatusExpectationFailed, gin.H{"errmsg": fmt.Sprintf("Unable to perform URL unescape: %s", err.Error())}) + return + } + + _, err = sync.ImportFile(sync.GlobalImporter, vpath, func(filePath, URI string) (interface{}, error) { + e.VideoURI = path.Join("$FILES$", strings.TrimPrefix(filePath, fic.FilesDir)) + return e.Update() + }) + if err != nil { + c.JSON(http.StatusExpectationFailed, gin.H{"errmsg": err.Error()}) + return + } + } + + c.JSON(http.StatusOK, true) + }) + + // Remove soluces from the database. + apiSyncRoutes.POST("/drop_soluces", func(c *gin.Context) { + exercices, err := fic.GetExercices() + if err != nil { + log.Println("Unable to GetExercices:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve exercices list."}) + return + } + + var errs error + for _, e := range exercices { + // Remove any published video + if len(e.VideoURI) > 0 && strings.HasPrefix(e.VideoURI, "$FILES$") { + vpath := path.Join(fic.FilesDir, strings.TrimPrefix(e.VideoURI, "$FILES$/")) + err = os.Remove(vpath) + if err != nil { + errs = multierr.Append(errs, fmt.Errorf("unable to delete published video (%q): %w", e.VideoURI, err)) + } + } + + // Clean the database + if len(e.VideoURI) > 0 || len(e.Resolution) > 0 { + e.VideoURI = "" + e.Resolution = "" + + _, err = e.Update() + if err != nil { + errs = multierr.Append(errs, fmt.Errorf("unable to update exercice (%d: %s): %w", e.Id, e.Title, err)) + } + } + } + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"errmsg": flatifySyncErrors(err)}) + } else { + c.JSON(http.StatusOK, true) + } + }) +} + +func declareSyncExercicesRoutes(router *gin.RouterGroup) { + router.POST("/exercices", func(c *gin.Context) { + theme := c.MustGet("theme").(*fic.Theme) + exceptions := sync.LoadThemeException(sync.GlobalImporter, theme) + + _, errs := sync.SyncExercices(sync.GlobalImporter, theme, exceptions) + c.JSON(http.StatusOK, flatifySyncErrors(errs)) + }) + apiSyncExercicesRoutes := router.Group("/exercices/:eid") + apiSyncExercicesRoutes.Use(ExerciceHandler) + apiSyncExercicesRoutes.POST("", func(c *gin.Context) { + theme := c.MustGet("theme").(*fic.Theme) + exercice := c.MustGet("exercice").(*fic.Exercice) + + exceptions := sync.LoadExerciceException(sync.GlobalImporter, theme, exercice, nil) + + _, _, _, errs := sync.SyncExercice(sync.GlobalImporter, theme, exercice.Path, nil, exceptions) + c.JSON(http.StatusOK, flatifySyncErrors(errs)) + }) + apiSyncExercicesRoutes.POST("/files", func(c *gin.Context) { + exercice := c.MustGet("exercice").(*fic.Exercice) + theme := c.MustGet("theme").(*fic.Theme) + + exceptions := sync.LoadExerciceException(sync.GlobalImporter, theme, exercice, nil) + + c.JSON(http.StatusOK, flatifySyncErrors(sync.ImportExerciceFiles(sync.GlobalImporter, exercice, exceptions))) + }) + apiSyncExercicesRoutes.POST("/fixurlid", func(c *gin.Context) { + exercice := c.MustGet("exercice").(*fic.Exercice) + if exercice.FixURLId() { + v, err := exercice.Update() + if err != nil { + log.Println("Unable to UpdateExercice after fixurlid:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs when saving the exercice."}) + return + } + + c.JSON(http.StatusOK, v) + } else { + c.AbortWithStatusJSON(http.StatusOK, 0) + } + }) + apiSyncExercicesRoutes.POST("/hints", func(c *gin.Context) { + exercice := c.MustGet("exercice").(*fic.Exercice) + theme := c.MustGet("theme").(*fic.Theme) + + exceptions := sync.LoadExerciceException(sync.GlobalImporter, theme, exercice, nil) + + _, errs := sync.SyncExerciceHints(sync.GlobalImporter, exercice, sync.ExerciceFlagsMap(sync.GlobalImporter, exercice), exceptions) + c.JSON(http.StatusOK, flatifySyncErrors(errs)) + }) + apiSyncExercicesRoutes.POST("/flags", func(c *gin.Context) { + exercice := c.MustGet("exercice").(*fic.Exercice) + theme := c.MustGet("theme").(*fic.Theme) + + exceptions := sync.LoadExerciceException(sync.GlobalImporter, theme, exercice, nil) + _, errs := sync.SyncExerciceFlags(sync.GlobalImporter, exercice, exceptions) + _, herrs := sync.SyncExerciceHints(sync.GlobalImporter, exercice, sync.ExerciceFlagsMap(sync.GlobalImporter, exercice), exceptions) + c.JSON(http.StatusOK, flatifySyncErrors(multierr.Append(errs, herrs))) + }) +} + +// autoSync tries to performs a smart synchronization, when in development environment. +// It'll sync most of modified things, and will delete out of sync data. +// Avoid using it in a production environment. +func autoSync(c *gin.Context) { + p := strings.Split(strings.TrimPrefix(c.Params.ByName("p"), "/"), "/") + + if !IsProductionEnv { + if err := sync.GlobalImporter.Sync(); err != nil { + lastSyncError = err.Error() + log.Println("Unable to sync.GI.Sync:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to perform the pull."}) + return + } + lastSyncError = "" + } + + themes, err := fic.GetThemes() + if err != nil { + log.Println("Unable to GetThemes:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve theme list."}) + return + } + + // No argument, do a deep sync + if len(p) == 0 { + if !IsProductionEnv { + for _, theme := range themes { + theme.DeleteDeep() + } + } + + st := sync.SyncDeep(sync.GlobalImporter) + c.JSON(http.StatusOK, st) + return + } + + var theTheme *fic.Theme + + // Find the given theme + for _, theme := range themes { + if theme.Path == p[0] { + theTheme = theme + break + } + } + + if theTheme == nil { + // The theme doesn't exists locally, perhaps it has not been imported already? + rThemes, err := sync.GetThemes(sync.GlobalImporter) + if err == nil { + for _, theme := range rThemes { + if theme == p[0] { + sync.SyncThemes(sync.GlobalImporter) + + themes, err := fic.GetThemes() + if err == nil { + for _, theme := range themes { + if theme.Path == p[0] { + theTheme = theme + break + } + } + } + + break + } + } + } + + if theTheme == nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Theme not found %q", p[0])}) + return + } + } + + if !IsProductionEnv { + exercices, err := theTheme.GetExercices() + if err == nil { + for _, exercice := range exercices { + if len(p) <= 1 || exercice.Path == path.Join(p[0], p[1]) { + exercice.DeleteDeep() + } + } + } + } + + exceptions := sync.LoadThemeException(sync.GlobalImporter, theTheme) + + var st []string + for _, se := range multierr.Errors(sync.SyncThemeDeep(sync.GlobalImporter, theTheme, 0, 250, exceptions)) { + st = append(st, se.Error()) + } + sync.EditDeepReport(&sync.SyncReport{Themes: map[string][]string{theTheme.Name: st}}, false) + sync.DeepSyncProgress = 255 + + resp, err := generation.FullGeneration() + if err == nil { + defer resp.Body.Close() + } + + c.JSON(http.StatusOK, st) +} + +func diffDBWithRemote() (map[string][]syncDiff, error) { + diffs := map[string][]syncDiff{} + + themes, err := fic.GetThemesExtended() + if err != nil { + return nil, err + } + + // Compare inner themes + for _, theme := range themes { + diffs[theme.Name], err = diffThemeWithRemote(theme) + if err != nil { + return nil, fmt.Errorf("Unable to diffThemeWithRemote: %w", err) + } + } + + return diffs, err +} + +func APIDiffDBWithRemote(c *gin.Context) { + diffs, err := diffDBWithRemote() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, diffs) +} diff --git a/admin/api/team.go b/admin/api/team.go index 0755468b..d5d21992 100644 --- a/admin/api/team.go +++ b/admin/api/team.go @@ -3,199 +3,639 @@ package api import ( "encoding/json" "fmt" + "log" + "net/http" + "strconv" "strings" "time" + "srs.epita.fr/fic-server/admin/pki" "srs.epita.fr/fic-server/libfic" - "github.com/julienschmidt/httprouter" + "github.com/gin-gonic/gin" ) -func init() { - router.GET("/api/teams.json", apiHandler( - func(httprouter.Params, []byte) (interface{}, error) { - return fic.ExportTeams() - })) - router.GET("/api/teams-binding", apiHandler( - func(httprouter.Params, []byte) (interface{}, error) { - return bindingTeams() - })) - router.GET("/api/teams-nginx-members", apiHandler( - func(httprouter.Params, []byte) (interface{}, error) { - return nginxGenMember() - })) - router.GET("/api/teams-tries.json", apiHandler( - func(httprouter.Params, []byte) (interface{}, error) { - return fic.GetTries(nil, nil) - })) +func declareTeamsRoutes(router *gin.RouterGroup) { + router.GET("/teams.json", func(c *gin.Context) { + teams, err := fic.ExportTeams(false) + if err != nil { + log.Println("Unable to ExportTeams:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during teams export."}) + return + } - router.GET("/api/teams/", apiHandler( - func(httprouter.Params, []byte) (interface{}, error) { - return fic.GetTeams() - })) - router.POST("/api/teams/", apiHandler(createTeam)) + c.JSON(http.StatusOK, teams) + }) + router.GET("/teams-members.json", func(c *gin.Context) { + teams, err := fic.ExportTeams(true) + if err != nil { + log.Println("Unable to ExportTeams:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during teams export."}) + return + } - router.GET("/api/teams/:tid/", apiHandler(teamHandler( - func(team fic.Team, _ []byte) (interface{}, error) { - return team, nil - }))) - router.PUT("/api/teams/:tid/", apiHandler(teamHandler(updateTeam))) - router.POST("/api/teams/:tid/", apiHandler(teamHandler(addTeamMember))) - router.DELETE("/api/teams/:tid/", apiHandler(teamHandler( - func(team fic.Team, _ []byte) (interface{}, error) { - return team.Delete() - }))) - router.GET("/api/teams/:tid/my.json", apiHandler(teamPublicHandler( - func(team *fic.Team, _ []byte) (interface{}, error) { - return fic.MyJSONTeam(team, true) - }))) - router.GET("/api/teams/:tid/wait.json", apiHandler(teamPublicHandler( - func(team *fic.Team, _ []byte) (interface{}, error) { - return fic.MyJSONTeam(team, false) - }))) - router.GET("/api/teams/:tid/stats.json", apiHandler(teamPublicHandler( - func(team *fic.Team, _ []byte) (interface{}, error) { - if team != nil { - return team.GetStats() - } else { - return fic.GetTeamsStats(nil) + c.JSON(http.StatusOK, teams) + }) + router.GET("/teams-associations.json", allAssociations) + router.GET("/teams-binding", bindingTeams) + router.GET("/teams-nginx", nginxGenTeams) + router.POST("/refine_colors", refineTeamsColors) + router.POST("/disableinactiveteams", disableInactiveTeams) + router.POST("/enableallteams", enableAllTeams) + router.GET("/teams-members-nginx", nginxGenMember) + router.GET("/teams-tries.json", func(c *gin.Context) { + tries, err := fic.GetTries(nil, nil) + if err != nil { + log.Println("Unable to GetTries:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieves tries."}) + return + } + + c.JSON(http.StatusOK, tries) + }) + + router.GET("/teams", func(c *gin.Context) { + teams, err := fic.GetTeams() + if err != nil { + log.Println("Unable to GetTeams:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during teams listing."}) + return + } + + c.JSON(http.StatusOK, teams) + }) + router.POST("/teams", createTeam) + + apiTeamsRoutes := router.Group("/teams/:tid") + apiTeamsRoutes.Use(TeamHandler) + apiTeamsRoutes.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("team").(*fic.Team)) + }) + apiTeamsRoutes.PUT("/", updateTeam) + apiTeamsRoutes.POST("/", addTeamMember) + apiTeamsRoutes.DELETE("/", deleteTeam) + apiTeamsRoutes.GET("/score-grid.json", func(c *gin.Context) { + team := c.MustGet("team").(*fic.Team) + + sg, err := team.ScoreGrid() + if err != nil { + log.Printf("Unable to get ScoreGrid(tid=%d): %s", team.Id, err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during score grid calculation."}) + return + } + + c.JSON(http.StatusOK, sg) + }) + + apiTeamsPublicRoutes := router.Group("/teams/:tid") + apiTeamsPublicRoutes.Use(TeamPublicHandler) + apiTeamsPublicRoutes.GET("/my.json", func(c *gin.Context) { + var team *fic.Team + if t, ok := c.Get("team"); ok && t != nil { + team = t.(*fic.Team) + } + tfile, err := fic.MyJSONTeam(team, true) + if err != nil { + log.Println("Unable to get MyJSONTeam:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during team JSON generation."}) + return + } + + c.JSON(http.StatusOK, tfile) + }) + apiTeamsPublicRoutes.GET("/wait.json", func(c *gin.Context) { + var team *fic.Team + if t, ok := c.Get("team"); ok && t != nil { + team = t.(*fic.Team) + } + tfile, err := fic.MyJSONTeam(team, false) + if err != nil { + log.Println("Unable to get MyJSONTeam:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during team JSON generation."}) + return + } + + c.JSON(http.StatusOK, tfile) + }) + apiTeamsPublicRoutes.GET("/stats.json", func(c *gin.Context) { + var team *fic.Team + if t, ok := c.Get("team"); ok && t != nil { + team = t.(*fic.Team) + } + if team != nil { + stats, err := team.GetStats() + if err != nil { + log.Println("Unable to get GetStats:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during stats calculation."}) + return } - }))) - router.GET("/api/teams/:tid/history.json", apiHandler(teamPublicHandler( - func(team *fic.Team, _ []byte) (interface{}, error) { - if team != nil { - return team.GetHistory() - } else { - return fic.GetTeamsStats(nil) + + c.JSON(http.StatusOK, stats) + } else { + stats, err := fic.GetTeamsStats(nil) + if err != nil { + log.Println("Unable to get GetTeamsStats:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during global stats calculation."}) + return } - }))) - router.DELETE("/api/teams/:tid/history.json", apiHandler(teamPublicHandler(delHistory))) - router.GET("/api/teams/:tid/tries", apiHandler(teamPublicHandler( - func(team *fic.Team, _ []byte) (interface{}, error) { - return fic.GetTries(team, nil) - }))) - router.GET("/api/teams/:tid/members", apiHandler(teamHandler( - func(team fic.Team, _ []byte) (interface{}, error) { - return team.GetMembers() - }))) - router.POST("/api/teams/:tid/members", apiHandler(teamHandler(addTeamMember))) - router.PUT("/api/teams/:tid/members", apiHandler(teamHandler(setTeamMember))) + + c.JSON(http.StatusOK, stats) + } + }) + apiTeamsRoutes.GET("/history.json", func(c *gin.Context) { + team := c.MustGet("team").(*fic.Team) + + history, err := team.GetHistory() + if err != nil { + log.Println("Unable to get GetHistory:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during history calculation."}) + return + } + + c.JSON(http.StatusOK, history) + }) + apiTeamsRoutes.PATCH("/history.json", updateHistory) + apiTeamsRoutes.DELETE("/history.json", delHistory) + apiTeamsPublicRoutes.GET("/tries", func(c *gin.Context) { + team := c.MustGet("team").(*fic.Team) + + tries, err := fic.GetTries(team, nil) + if err != nil { + log.Println("Unable to GetTries:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during tries calculation."}) + return + } + + c.JSON(http.StatusOK, tries) + }) + apiTeamsRoutes.GET("/members", func(c *gin.Context) { + team := c.MustGet("team").(*fic.Team) + + members, err := team.GetMembers() + if err != nil { + log.Println("Unable to GetMembers:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during members retrieval."}) + return + } + + c.JSON(http.StatusOK, members) + }) + apiTeamsRoutes.POST("/members", addTeamMember) + apiTeamsRoutes.PUT("/members", setTeamMember) + + declareTeamsPasswordRoutes(apiTeamsRoutes) + declareTeamClaimsRoutes(apiTeamsRoutes) + declareTeamCertificateRoutes(apiTeamsRoutes) + + // Import teams from cyberrange + router.POST("/cyberrange-teams.json", importTeamsFromCyberrange) } -func nginxGenMember() (string, error) { - if teams, err := fic.GetTeams(); err != nil { - return "", err +func TeamHandler(c *gin.Context) { + tid, err := strconv.ParseInt(string(c.Params.ByName("tid")), 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid team identifier"}) + return + } + + team, err := fic.GetTeam(tid) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Team not found"}) + return + } + + c.Set("team", team) + + c.Next() +} + +func TeamPublicHandler(c *gin.Context) { + tid, err := strconv.ParseInt(string(c.Params.ByName("tid")), 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid team identifier"}) + return + } + + if tid != 0 { + team, err := fic.GetTeam(tid) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Team not found"}) + return + } + + c.Set("team", team) } else { - ret := "" + c.Set("team", nil) + } + + c.Next() +} + +func nginxGenTeams(c *gin.Context) { + teams, err := fic.GetTeams() + if err != nil { + log.Println("Unable to GetTeams:", err.Error()) + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + ret := "" + for _, team := range teams { + ret += fmt.Sprintf(" if ($remote_user = \"%s\") { set $team \"%d\"; }\n", strings.ToLower(team.Name), team.Id) + } + + c.String(http.StatusOK, ret) +} + +func nginxGenMember(c *gin.Context) { + teams, err := fic.GetTeams() + if err != nil { + log.Println("Unable to GetTeams:", err.Error()) + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + ret := "" + for _, team := range teams { + if members, err := team.GetMembers(); err == nil { + for _, member := range members { + ret += fmt.Sprintf(" if ($remote_user = \"%s\") { set $team \"%d\"; }\n", member.Nickname, team.Id) + } + } else { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + } + + c.String(http.StatusOK, ret) +} + +func bindingTeams(c *gin.Context) { + teams, err := fic.GetTeams() + if err != nil { + log.Println("Unable to GetTeams:", err.Error()) + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + ret := "" + for _, team := range teams { + if members, err := team.GetMembers(); err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } else { + var mbs []string + for _, member := range members { + mbs = append(mbs, fmt.Sprintf("%s %s", member.Firstname, member.Lastname)) + } + ret += fmt.Sprintf("%d;%s;%s\n", team.Id, team.Name, strings.Join(mbs, ";")) + } + } + + c.String(http.StatusOK, ret) +} + +type teamAssociation struct { + Association string `json:"association"` + TeamId int64 `json:"team_id"` +} + +func allAssociations(c *gin.Context) { + teams, err := fic.GetTeams() + if err != nil { + log.Println("Unable to GetTeams:", err.Error()) + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + var ret []teamAssociation + + for _, team := range teams { + assocs, err := pki.GetTeamAssociations(TeamsDir, team.Id) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + for _, a := range assocs { + ret = append(ret, teamAssociation{a, team.Id}) + } + } + + c.JSON(http.StatusOK, ret) +} + +func importTeamsFromCyberrange(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"errmsg": "Failed to get file: " + err.Error()}) + return + } + + src, err := file.Open() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"errmsg": "Failed to open file: " + err.Error()}) + return + } + defer src.Close() + + var ut []fic.CyberrangeTeamBase + err = json.NewDecoder(src).Decode(&fic.CyberrangeAPIResponse{Data: &ut}) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + teams, err := fic.GetTeams() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Impossible de récupérer la liste des équipes actuelles: %s", err.Error())}) + return + } + + for _, crteam := range ut { + var exist_team *fic.Team for _, team := range teams { - if members, err := team.GetMembers(); err == nil { - for _, member := range members { - ret += fmt.Sprintf(" if ($remote_user = \"%s\") { set $team \"%d\"; }\n", member.Nickname, team.Id) - } - } else { - return "", err + if team.Name == crteam.Name || team.ExternalId == crteam.UUID { + exist_team = team + break } } - return ret, nil - } -} + if exist_team != nil { + exist_team.Name = crteam.Name + exist_team.ExternalId = crteam.UUID + _, err = exist_team.Update() + } else { + exist_team, err = fic.CreateTeam(crteam.Name, fic.RandomColor().ToRGB(), crteam.UUID) + } -func bindingTeams() (string, error) { - if teams, err := fic.GetTeams(); err != nil { - return "", err - } else { - ret := "" - for _, team := range teams { - if members, err := team.GetMembers(); err != nil { - return "", err - } else { - var mbs []string - for _, member := range members { - mbs = append(mbs, fmt.Sprintf("%s %s", member.Firstname, member.Lastname)) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Impossible d'ajouter/de modifier l'équipe %v: %s", crteam, err.Error())}) + return + } + + // Import members + if c.DefaultQuery("nomembers", "0") != "" && len(crteam.Members) > 0 { + exist_team.ClearMembers() + + for _, member := range crteam.Members { + _, err = exist_team.AddMember(member.Name, "", member.Nickname, exist_team.Name) + if err != nil { + log.Printf("Unable to add member %q to team %s (tid=%d): %s", member.UUID, exist_team.Name, exist_team.Id, err.Error()) } - ret += fmt.Sprintf("%d;%s;%s\n", team.Id, team.Name, strings.Join(mbs, ";")) } } - return ret, nil - } -} - -type uploadedTeam struct { - Name string - Color uint32 -} - -type uploadedMember struct { - Firstname string - Lastname string - Nickname string - Company string -} - -func createTeam(_ httprouter.Params, body []byte) (interface{}, error) { - var ut uploadedTeam - if err := json.Unmarshal(body, &ut); err != nil { - return nil, err } - return fic.CreateTeam(strings.TrimSpace(ut.Name), ut.Color) + teams, err = fic.GetTeams() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Impossible de récupérer la liste des équipes après import: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, teams) } -func updateTeam(team fic.Team, body []byte) (interface{}, error) { +func createTeam(c *gin.Context) { var ut fic.Team - if err := json.Unmarshal(body, &ut); err != nil { - return nil, err + err := c.ShouldBindJSON(&ut) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + if ut.Color == 0 { + ut.Color = fic.RandomColor().ToRGB() + } + + team, err := fic.CreateTeam(strings.TrimSpace(ut.Name), ut.Color, ut.ExternalId) + if err != nil { + log.Println("Unable to CreateTeam:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during team creation."}) + return + } + + c.JSON(http.StatusOK, team) +} + +func updateTeam(c *gin.Context) { + team := c.MustGet("team").(*fic.Team) + + var ut fic.Team + err := c.ShouldBindJSON(&ut) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return } ut.Id = team.Id - if _, err := ut.Update(); err != nil { - return nil, err + if ut.Password != nil && *ut.Password == "" { + ut.Password = nil } - return ut, nil + _, err = ut.Update() + if err != nil { + log.Println("Unable to updateTeam:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during team updating."}) + return + } + + c.JSON(http.StatusOK, ut) } -func addTeamMember(team fic.Team, body []byte) (interface{}, error) { - var members []uploadedMember - if err := json.Unmarshal(body, &members); err != nil { - return nil, err +func refineTeamsColors(c *gin.Context) { + teams, err := fic.GetTeams() + if err != nil { + log.Println("Unable to GetTeams:", err.Error()) + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + for i, team := range teams { + team.Color = fic.HSL{ + H: float64(i)/float64(len(teams)) - 0.2, + S: float64(1) / float64(1+i%2), + L: 0.25 + float64(0.5)/float64(1+i%3), + }.ToRGB() + + _, err = team.Update() + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + } + + c.JSON(http.StatusOK, teams) +} + +func disableInactiveTeams(c *gin.Context) { + teams, err := fic.GetTeams() + if err != nil { + log.Println("Unable to GetTeams:", err.Error()) + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + for _, team := range teams { + var serials []uint64 + serials, err = pki.GetTeamSerials(TeamsDir, team.Id) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + var assocs []string + assocs, err = pki.GetTeamAssociations(TeamsDir, team.Id) + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + if len(serials) == 0 && len(assocs) == 0 { + if team.Active { + team.Active = false + team.Update() + } + } else if !team.Active { + team.Active = true + team.Update() + } + } + + c.JSON(http.StatusOK, true) +} + +func enableAllTeams(c *gin.Context) { + teams, err := fic.GetTeams() + if err != nil { + log.Println("Unable to GetTeams:", err.Error()) + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + for _, team := range teams { + if !team.Active { + team.Active = true + team.Update() + } + } + + c.JSON(http.StatusOK, true) +} + +func deleteTeam(c *gin.Context) { + team := c.MustGet("team").(*fic.Team) + + assocs, err := pki.GetTeamAssociations(TeamsDir, team.Id) + if err != nil { + log.Printf("Unable to GetTeamAssociations(tid=%d): %s", team.Id, err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs when trying to retrieve team association."}) + return + } + + for _, assoc := range assocs { + err = pki.DeleteTeamAssociation(TeamsDir, assoc) + if err != nil { + log.Printf("Unable to DeleteTeamAssociation(assoc=%s): %s", assoc, err.Error()) + return + } + } + + _, err = team.Delete() + if err != nil { + log.Println("Unable to deleteTeam:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during team deletion."}) + return + } + + c.JSON(http.StatusOK, true) +} + +func addTeamMember(c *gin.Context) { + team := c.MustGet("team").(*fic.Team) + + var members []fic.Member + err := c.ShouldBindJSON(&members) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return } for _, member := range members { - team.AddMember(strings.TrimSpace(member.Firstname), strings.TrimSpace(member.Lastname), strings.TrimSpace(member.Nickname), strings.TrimSpace(member.Company)) + _, err := team.AddMember(strings.TrimSpace(member.Firstname), strings.TrimSpace(member.Lastname), strings.TrimSpace(member.Nickname), strings.TrimSpace(member.Company)) + if err != nil { + log.Println("Unable to AddMember:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during member creation."}) + return + } } - return team.GetMembers() + mmbrs, err := team.GetMembers() + if err != nil { + log.Println("Unable to retrieve members list:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve members list."}) + return + } + + c.JSON(http.StatusOK, mmbrs) } -func setTeamMember(team fic.Team, body []byte) (interface{}, error) { - var members []uploadedMember - if err := json.Unmarshal(body, &members); err != nil { - return nil, err - } - +func setTeamMember(c *gin.Context) { + team := c.MustGet("team").(*fic.Team) team.ClearMembers() - for _, member := range members { - team.AddMember(strings.TrimSpace(member.Firstname), strings.TrimSpace(member.Lastname), strings.TrimSpace(member.Nickname), strings.TrimSpace(member.Company)) - } - - return team.GetMembers() + addTeamMember(c) } type uploadedHistory struct { - Kind string - Time time.Time - Primary *int64 - Secondary *int64 + Kind string + Time time.Time + Primary *int64 + Secondary *int64 + Coefficient float32 } -func delHistory(team *fic.Team, body []byte) (interface{}, error) { +func updateHistory(c *gin.Context) { + team := c.MustGet("team").(*fic.Team) + var uh uploadedHistory - if err := json.Unmarshal(body, &uh); err != nil { - return nil, err + err := c.ShouldBindJSON(&uh) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return } - return team.DelHistoryItem(uh.Kind, uh.Time, uh.Primary, uh.Secondary) + var givenId int64 + if uh.Secondary != nil { + givenId = *uh.Secondary + } else if uh.Primary != nil { + givenId = *uh.Primary + } + + _, err = team.UpdateHistoryCoeff(uh.Kind, uh.Time, givenId, uh.Coefficient) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to update this history line: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, true) +} + +func delHistory(c *gin.Context) { + team := c.MustGet("team").(*fic.Team) + + var uh uploadedHistory + err := c.ShouldBindJSON(&uh) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + _, err = team.DelHistoryItem(uh.Kind, uh.Time, uh.Primary, uh.Secondary) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to delete this history line: %s", err.Error())}) + return + } + + c.JSON(http.StatusOK, true) } diff --git a/admin/api/theme.go b/admin/api/theme.go index 8ad60fae..c1057397 100644 --- a/admin/api/theme.go +++ b/admin/api/theme.go @@ -1,89 +1,95 @@ package api import ( - "encoding/json" - "errors" "fmt" + "log" + "net/http" + "path" + "reflect" "strconv" + "strings" "srs.epita.fr/fic-server/admin/sync" "srs.epita.fr/fic-server/libfic" + "srs.epita.fr/fic-server/settings" - "github.com/julienschmidt/httprouter" + "github.com/gin-gonic/gin" ) -func init() { - router.GET("/api/themes", apiHandler(listThemes)) - router.POST("/api/themes", apiHandler(createTheme)) - router.GET("/api/themes.json", apiHandler(exportThemes)) - router.GET("/api/files-bindings", apiHandler(bindingFiles)) +func declareThemesRoutes(router *gin.RouterGroup) { + router.GET("/themes", listThemes) + router.POST("/themes", createTheme) + router.GET("/themes.json", exportThemes) + router.GET("/session-forensic.yaml", func(c *gin.Context) { + if s, err := settings.ReadSettings(path.Join(settings.SettingsDir, settings.SettingsFile)); err != nil { + log.Printf("Unable to ReadSettings: %s", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during settings reading."}) + return - router.GET("/api/themes/:thid", apiHandler(themeHandler(showTheme))) - router.PUT("/api/themes/:thid", apiHandler(themeHandler(updateTheme))) - router.DELETE("/api/themes/:thid", apiHandler(themeHandler(deleteTheme))) + } else if challengeinfo, err := sync.GetFileContent(sync.GlobalImporter, "challenge.json"); err != nil { + log.Println("Unable to retrieve challenge.json:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to retrive challenge.json: %s", err.Error())}) + return + } else if ch, err := settings.ReadChallengeInfo(challengeinfo); err != nil { + log.Printf("Unable to ReadChallengeInfo: %s", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during challenge info reading."}) + return + } else if sf, err := fic.GenZQDSSessionFile(ch, s); err != nil { + log.Printf("Unable to GenZQDSSessionFile: %s", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during session file generation."}) + return + } else { + c.JSON(http.StatusOK, sf) + } + }) + router.GET("/files-bindings", bindingFiles) - router.GET("/api/themes/:thid/exercices", apiHandler(themeHandler(listThemedExercices))) - router.POST("/api/themes/:thid/exercices", apiHandler(themeHandler(createExercice))) + apiThemesRoutes := router.Group("/themes/:thid") + apiThemesRoutes.Use(ThemeHandler) + apiThemesRoutes.GET("", showTheme) + apiThemesRoutes.PUT("", updateTheme) + apiThemesRoutes.DELETE("", deleteTheme) - router.GET("/api/themes/:thid/exercices/:eid", apiHandler(exerciceHandler(showExercice))) - router.PUT("/api/themes/:thid/exercices/:eid", apiHandler(exerciceHandler(updateExercice))) - router.DELETE("/api/themes/:thid/exercices/:eid", apiHandler(exerciceHandler(deleteExercice))) + apiThemesRoutes.POST("/diff-sync", APIDiffThemeWithRemote) - router.GET("/api/themes/:thid/exercices/:eid/files", apiHandler(exerciceHandler(listExerciceFiles))) - router.POST("/api/themes/:thid/exercices/:eid/files", apiHandler(exerciceHandler(createExerciceFile))) + apiThemesRoutes.GET("/exercices_stats.json", getThemedExercicesStats) - router.GET("/api/themes/:thid/exercices/:eid/hints", apiHandler(exerciceHandler(listExerciceHints))) - router.POST("/api/themes/:thid/exercices/:eid/hints", apiHandler(exerciceHandler(createExerciceHint))) - - router.GET("/api/themes/:thid/exercices/:eid/keys", apiHandler(exerciceHandler(listExerciceKeys))) - router.POST("/api/themes/:thid/exercices/:eid/keys", apiHandler(exerciceHandler(createExerciceKey))) + declareExercicesRoutes(apiThemesRoutes) // Remote - router.GET("/api/remote/themes", apiHandler(sync.ApiListRemoteThemes)) - router.GET("/api/remote/themes/:thid", apiHandler(sync.ApiGetRemoteTheme)) - router.GET("/api/remote/themes/:thid/exercices", apiHandler(themeHandler(sync.ApiListRemoteExercices))) - - // Synchronize - router.POST("/api/sync/deep", apiHandler( - func(_ httprouter.Params, _ []byte) (interface{}, error) { - return sync.SyncDeep(sync.GlobalImporter), nil - })) - router.POST("/api/sync/themes", apiHandler( - func(_ httprouter.Params, _ []byte) (interface{}, error) { - return sync.SyncThemes(sync.GlobalImporter), nil - })) - router.POST("/api/sync/themes/:thid/exercices", apiHandler(themeHandler( - func(theme fic.Theme, _ []byte) (interface{}, error) { - return sync.SyncExercices(sync.GlobalImporter, theme), nil - }))) - router.POST("/api/sync/themes/:thid/exercices/:eid/files", apiHandler(exerciceHandler( - func(exercice fic.Exercice, _ []byte) (interface{}, error) { - return sync.SyncExerciceFiles(sync.GlobalImporter, exercice), nil - }))) - router.POST("/api/sync/themes/:thid/exercices/:eid/hints", apiHandler(exerciceHandler( - func(exercice fic.Exercice, _ []byte) (interface{}, error) { - return sync.SyncExerciceHints(sync.GlobalImporter, exercice), nil - }))) - router.POST("/api/sync/themes/:thid/exercices/:eid/keys", apiHandler(exerciceHandler( - func(exercice fic.Exercice, _ []byte) (interface{}, error) { - return sync.SyncExerciceKeys(sync.GlobalImporter, exercice), nil - }))) - router.POST("/api/sync/themes/:thid/exercices/:eid/quiz", apiHandler(exerciceHandler( - func(exercice fic.Exercice, _ []byte) (interface{}, error) { - return sync.SyncExerciceMCQ(sync.GlobalImporter, exercice), nil - }))) - - router.POST("/api/sync/themes/:thid/fixurlid", apiHandler(themeHandler( - func(theme fic.Theme, _ []byte) (interface{}, error) { - if theme.FixURLId() { - return theme.Update() - } - return 0, nil - }))) - router.POST("/api/sync/fixurlids", apiHandler(fixAllURLIds)) + router.GET("/remote/themes", sync.ApiListRemoteThemes) + router.GET("/remote/themes/:thid", sync.ApiGetRemoteTheme) + router.GET("/remote/themes/:thid/exercices", sync.ApiListRemoteExercices) } -func fixAllURLIds(_ httprouter.Params, _ []byte) (interface{}, error) { +type Theme struct { + *fic.Theme + ForgeLink string `json:"forge_link,omitempty"` +} + +func ThemeHandler(c *gin.Context) { + thid, err := strconv.ParseInt(string(c.Params.ByName("thid")), 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Invalid theme identifier"}) + return + } + + if thid == 0 { + c.Set("theme", &fic.StandaloneExercicesTheme) + } else { + theme, err := fic.GetTheme(thid) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Theme not found"}) + return + } + + c.Set("theme", theme) + } + + c.Next() +} + +func fixAllURLIds(c *gin.Context) { nbFix := 0 if themes, err := fic.GetThemes(); err == nil { for _, theme := range themes { @@ -103,92 +109,268 @@ func fixAllURLIds(_ httprouter.Params, _ []byte) (interface{}, error) { } } - return nbFix, nil + c.JSON(http.StatusOK, nbFix) } -func bindingFiles(_ httprouter.Params, body []byte) (interface{}, error) { - if files, err := fic.GetFiles(); err != nil { - return "", err - } else { - ret := "" - for _, file := range files { - ret += fmt.Sprintf("%s;%s\n", file.GetOrigin(), file.Path) +func bindingFiles(c *gin.Context) { + files, err := fic.GetFiles() + if err != nil { + c.AbortWithError(http.StatusInternalServerError, err) + return + } + + ret := "" + for _, file := range files { + ret += fmt.Sprintf("%s;%s\n", file.GetOrigin(), file.Path) + } + + c.String(http.StatusOK, ret) +} + +func listThemes(c *gin.Context) { + themes, err := fic.GetThemes() + if err != nil { + log.Println("Unable to listThemes:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs when trying to list themes."}) + return + } + + if has, _ := fic.HasStandaloneExercice(); has { + themes = append([]*fic.Theme{&fic.StandaloneExercicesTheme}, themes...) + } + + c.JSON(http.StatusOK, themes) +} + +func exportThemes(c *gin.Context) { + themes, err := fic.ExportThemes() + if err != nil { + log.Println("Unable to exportthemes:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs when trying to export themes."}) + return + } + + c.JSON(http.StatusOK, themes) +} + +func showTheme(c *gin.Context) { + theme := c.MustGet("theme").(*fic.Theme) + + var forgelink string + if fli, ok := sync.GlobalImporter.(sync.ForgeLinkedImporter); ok { + if u, _ := fli.GetThemeLink(theme); u != nil { + forgelink = u.String() } - return ret, nil } + + c.JSON(http.StatusOK, Theme{theme, forgelink}) } -func getExercice(args []string) (fic.Exercice, error) { - if tid, err := strconv.Atoi(string(args[0])); err != nil { - return fic.Exercice{}, err - } else if theme, err := fic.GetTheme(tid); err != nil { - return fic.Exercice{}, err - } else if eid, err := strconv.Atoi(string(args[1])); err != nil { - return fic.Exercice{}, err - } else { - return theme.GetExercice(eid) - } -} - -func listThemes(_ httprouter.Params, _ []byte) (interface{}, error) { - return fic.GetThemes() -} - -func exportThemes(_ httprouter.Params, _ []byte) (interface{}, error) { - return fic.ExportThemes() -} - -func showTheme(theme fic.Theme, _ []byte) (interface{}, error) { - return theme, nil -} - -func listThemedExercices(theme fic.Theme, _ []byte) (interface{}, error) { - return theme.GetExercices() -} - -func showThemedExercice(theme fic.Theme, exercice fic.Exercice, body []byte) (interface{}, error) { - return exercice, nil -} - -type uploadedTheme struct { - Name string - URLId string - Authors string - Intro string -} - -func createTheme(_ httprouter.Params, body []byte) (interface{}, error) { - var ut uploadedTheme - if err := json.Unmarshal(body, &ut); err != nil { - return nil, err +func createTheme(c *gin.Context) { + var ut fic.Theme + err := c.ShouldBindJSON(&ut) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return } if len(ut.Name) == 0 { - return nil, errors.New("Theme's name not filled") + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Theme's name not filled"}) + return } - return fic.CreateTheme(ut.Name, ut.URLId, ut.Authors, ut.Intro) + th, err := fic.CreateTheme(&ut) + if err != nil { + log.Println("Unable to createTheme:", err.Error()) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "An error occurs during theme creation."}) + return + } + + c.JSON(http.StatusOK, th) } -func updateTheme(theme fic.Theme, body []byte) (interface{}, error) { +func updateTheme(c *gin.Context) { + theme := c.MustGet("theme").(*fic.Theme) + var ut fic.Theme - if err := json.Unmarshal(body, &ut); err != nil { - return nil, err + err := c.ShouldBindJSON(&ut) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return } ut.Id = theme.Id if len(ut.Name) == 0 { - return nil, errors.New("Theme's name not filled") + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Theme's name not filled"}) + return } if _, err := ut.Update(); err != nil { - return nil, err - } else { - return ut, nil + log.Println("Unable to updateTheme:", err.Error()) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "An error occurs during theme update."}) + return } + + if theme.Locked != ut.Locked { + exercices, err := theme.GetExercices() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + for _, exercice := range exercices { + if exercice.Disabled != ut.Locked { + exercice.Disabled = ut.Locked + _, err = exercice.Update() + if err != nil { + log.Println("Unable to enable/disable exercice: ", exercice.Id, err.Error()) + } + } + } + } + + c.JSON(http.StatusOK, ut) } -func deleteTheme(theme fic.Theme, _ []byte) (interface{}, error) { - return theme.Delete() +func deleteTheme(c *gin.Context) { + theme := c.MustGet("theme").(*fic.Theme) + + _, err := theme.Delete() + if err != nil { + log.Println("Unable to deleteTheme:", err.Error()) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "An error occurs during theme deletion."}) + return + } + + c.JSON(http.StatusOK, true) +} + +func getThemedExercicesStats(c *gin.Context) { + theme := c.MustGet("theme").(*fic.Theme) + + exercices, err := theme.GetExercices() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("Unable to fetch exercices: %s", err.Error())}) + return + } + + ret := []exerciceStats{} + for _, e := range exercices { + ret = append(ret, exerciceStats{ + IdExercice: e.Id, + TeamTries: e.TriedTeamCount(), + TotalTries: e.TriedCount(), + SolvedCount: e.SolvedCount(), + FlagSolved: e.FlagSolved(), + MCQSolved: e.MCQSolved(), + }) + } + c.JSON(http.StatusOK, ret) +} + +func diffThemeWithRemote(theme *fic.Theme) ([]syncDiff, error) { + var diffs []syncDiff + + // Compare theme attributes + theme_remote, err := sync.GetRemoteTheme(theme.Path) + if err != nil { + return nil, err + } + + for _, field := range reflect.VisibleFields(reflect.TypeOf(*theme)) { + if ((field.Name == "Image") && path.Base(reflect.ValueOf(*theme_remote).FieldByName(field.Name).String()) != path.Base(reflect.ValueOf(*theme).FieldByName(field.Name).String())) || (field.Name != "Image" && !reflect.ValueOf(*theme_remote).FieldByName(field.Name).Equal(reflect.ValueOf(*theme).FieldByName(field.Name))) { + if !field.IsExported() || field.Name == "Id" || field.Name == "IdTheme" || field.Name == "IssueKind" || field.Name == "BackgroundColor" { + continue + } + + diffs = append(diffs, syncDiff{ + Field: field.Name, + Link: fmt.Sprintf("themes/%d", theme.Id), + Before: reflect.ValueOf(*theme).FieldByName(field.Name).Interface(), + After: reflect.ValueOf(*theme_remote).FieldByName(field.Name).Interface(), + }) + } + } + + // Compare exercices list + exercices, err := theme.GetExercices() + if err != nil { + return nil, fmt.Errorf("Unable to GetExercices: %w", err) + } + + exercices_remote, err := sync.ListRemoteExercices(theme.Path) + if err != nil { + return nil, fmt.Errorf("Unable to ListRemoteExercices: %w", err) + } + + var not_found []string + var extra_found []string + + for _, exercice_remote := range exercices_remote { + found := false + for _, exercice := range exercices { + if exercice.Path[strings.Index(exercice.Path, "/")+1:] == exercice_remote { + found = true + break + } + } + + if !found { + not_found = append(not_found, exercice_remote) + } + } + + for _, exercice := range exercices { + found := false + for _, exercice_remote := range exercices_remote { + if exercice.Path[strings.Index(exercice.Path, "/")+1:] == exercice_remote { + found = true + break + } + } + + if !found { + extra_found = append(extra_found, exercice.Path[strings.Index(exercice.Path, "/")+1:]) + } + } + + if len(not_found) > 0 || len(extra_found) > 0 { + diffs = append(diffs, syncDiff{ + Field: "theme.Exercices", + Link: fmt.Sprintf("themes/%d", theme.Id), + Before: strings.Join(extra_found, ", "), + After: strings.Join(not_found, ", "), + }) + } + + // Compare inner exercices + for i, exercice := range exercices { + exdiffs, err := diffExerciceWithRemote(exercice, theme) + if err != nil { + return nil, fmt.Errorf("Unable to diffExerciceWithRemote: %w", err) + } + + for _, exdiff := range exdiffs { + if theme.Id == 0 { + exdiff.Field = fmt.Sprintf("exercices[%d].%s", exercice.Id, exdiff.Field) + } else { + exdiff.Field = fmt.Sprintf("exercices[%d].%s", i, exdiff.Field) + } + diffs = append(diffs, exdiff) + } + } + + return diffs, err +} + +func APIDiffThemeWithRemote(c *gin.Context) { + theme := c.MustGet("theme").(*fic.Theme) + + diffs, err := diffThemeWithRemote(theme) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"errmsg": err.Error()}) + return + } + + c.JSON(http.StatusOK, diffs) } diff --git a/admin/api/version.go b/admin/api/version.go index a65f9c85..52cb0726 100644 --- a/admin/api/version.go +++ b/admin/api/version.go @@ -1,13 +1,15 @@ package api import ( - "github.com/julienschmidt/httprouter" + "net/http" + + "github.com/gin-gonic/gin" ) -func init() { - router.GET("/api/version", apiHandler(showVersion)) +func DeclareVersionRoutes(router *gin.RouterGroup) { + router.GET("/version", showVersion) } -func showVersion(_ httprouter.Params, body []byte) (interface{}, error) { - return map[string]interface{}{"version": 0.5}, nil +func showVersion(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"version": 1.0}) } diff --git a/admin/app.go b/admin/app.go new file mode 100644 index 00000000..1ba1910e --- /dev/null +++ b/admin/app.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "log" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "srs.epita.fr/fic-server/admin/api" + "srs.epita.fr/fic-server/settings" +) + +type App struct { + router *gin.Engine + srv *http.Server + cfg *settings.Settings + bind string +} + +func NewApp(cfg *settings.Settings, baseURL string, bind string) App { + if !cfg.WorkInProgress { + gin.SetMode(gin.ReleaseMode) + } + gin.ForceConsoleColor() + router := gin.Default() + + api.DeclareRoutes(router.Group("")) + + var baserouter *gin.RouterGroup + if len(baseURL) > 0 { + router.GET("/", func(c *gin.Context) { + c.Redirect(http.StatusFound, baseURL) + }) + router.GET(filepath.Dir(baseURL)+"/files/*_", func(c *gin.Context) { + path := c.Request.URL.Path + c.Redirect(http.StatusFound, filepath.Join(baseURL, strings.TrimPrefix(path, filepath.Dir(baseURL)))) + }) + + baserouter = router.Group(baseURL) + + api.DeclareRoutes(baserouter) + declareStaticRoutes(baserouter, cfg, baseURL) + } else { + declareStaticRoutes(router.Group(""), cfg, "") + } + + app := App{ + router: router, + bind: bind, + } + + return app +} + +func (app *App) Start() { + app.srv = &http.Server{ + Addr: app.bind, + Handler: app.router, + ReadHeaderTimeout: 15 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } + + log.Printf("Ready, listening on %s\n", app.bind) + if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) + } +} + +func (app *App) Stop() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := app.srv.Shutdown(ctx); err != nil { + log.Fatal("Server Shutdown:", err) + } +} diff --git a/admin/fill_exercices.sh b/admin/fill_exercices.sh deleted file mode 100755 index e2ce2141..00000000 --- a/admin/fill_exercices.sh +++ /dev/null @@ -1,262 +0,0 @@ -#!/bin/bash - -BASEURL="http://localhost:8081" -BASEURI="https://owncloud.srs.epita.fr/remote.php/webdav/FIC 2018" -BASEFILE="/mnt/fic/" -CLOUDPASS="$CLOUD_USER:$CLOUD_PASS" - -new_theme() { - NAME=`echo $1 | sed 's/"/\\\\"/g'` - AUTHORS=`echo $2 | sed 's/"/\\\\"/g'` - curl -f -s -d "{\"name\": \"$NAME\", \"authors\": \"$AUTHORS\"}" "${BASEURL}/api/themes" | - grep -Eo '"id":[0-9]+,' | grep -Eo "[0-9]+" -} - -new_exercice() { - THEME="$1" - TITLE=`echo "$2" | sed 's/"/\\\\"/g'` - STATEMENT=`echo "$3" | sed 's/"/\\\\"/g' | sed ':a;N;$!ba;s/\n/