diff --git a/.drone.yml b/.drone.yml index 132e7643..071f44ca 100644 --- a/.drone.yml +++ b/.drone.yml @@ -22,6 +22,7 @@ steps: - go get -v -d srs.epita.fr/fic-server/frontend - go get -v -d srs.epita.fr/fic-server/dashboard - go get -v -d srs.epita.fr/fic-server/repochecker + - go get -v -d srs.epita.fr/fic-server/repochecker/epita - go get -v -d srs.epita.fr/fic-server/qa - mkdir deploy @@ -39,6 +40,7 @@ steps: - go vet -v -buildvcs=false srs.epita.fr/fic-server/frontend - go vet -v -buildvcs=false srs.epita.fr/fic-server/dashboard - go vet -v -buildvcs=false srs.epita.fr/fic-server/repochecker + - go vet -v -buildvcs=false srs.epita.fr/fic-server/repochecker/epita - go vet -v -buildvcs=false srs.epita.fr/fic-server/qa - go vet -v -buildvcs=false srs.epita.fr/fic-server/settings @@ -93,7 +95,9 @@ steps: - name: build repochecker image: golang:alpine commands: + - apk --no-cache add build-base - go build -buildvcs=false --tags checkupdate -v -o deploy/repochecker-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} srs.epita.fr/fic-server/repochecker + - go build -buildvcs=false -buildmode=plugin -v -o deploy/repochecker-epita-rules-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}.so srs.epita.fr/fic-server/repochecker/epita - grep "const version" repochecker/update.go | sed -r 's/^.*=\s*(\S.*)$/\1/' > deploy/repochecker.version environment: CGO_ENABLED: 0 @@ -332,6 +336,7 @@ steps: - name: build admin image: golang:alpine commands: + - apk --no-cache add build-base - go build -v -buildvcs=false -o deploy/admin-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} srs.epita.fr/fic-server/admin environment: CGO_ENABLED: 0 @@ -376,6 +381,7 @@ steps: - name: build repochecker image: golang:alpine commands: + - apk --no-cache add build-base - go build -buildvcs=false --tags checkupdate -v -o deploy/repochecker-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} srs.epita.fr/fic-server/repochecker environment: CGO_ENABLED: 0 diff --git a/Dockerfile-admin b/Dockerfile-admin index 62c55c9c..8f476c96 100644 --- a/Dockerfile-admin +++ b/Dockerfile-admin @@ -8,9 +8,12 @@ COPY go.mod go.sum ./ COPY settings settings/ COPY libfic ./libfic/ COPY admin ./admin/ +COPY repochecker ./repochecker/ -RUN go get -d -v ./admin && \ - go build -v -buildvcs=false -o admin/admin ./admin +RUN apk add --no-cache build-base && \ + go get -d -v ./admin && \ + go build -v -buildvcs=false -o admin/admin ./admin && \ + go build -v -buildmode=plugin -o repochecker/epita-rules.so ./repochecker/epita FROM alpine:3.16 @@ -29,3 +32,4 @@ WORKDIR /srv ENTRYPOINT ["/srv/admin", "-bind=:8081", "-baseurl=/admin/"] COPY --from=gobuild /go/src/srs.epita.fr/fic-server/admin/admin /srv/admin +COPY --from=gobuild /go/src/srs.epita.fr/fic-server/repochecker/epita-rules.so /srv/epita-rules.so diff --git a/Dockerfile-repochecker b/Dockerfile-repochecker index 1c8077dc..f6a9a036 100644 --- a/Dockerfile-repochecker +++ b/Dockerfile-repochecker @@ -10,14 +10,17 @@ COPY libfic ./libfic/ COPY admin ./admin/ COPY repochecker ./repochecker/ -RUN go get -d -v ./repochecker && \ - go build -v -o repochecker/repochecker ./repochecker +RUN apk add --no-cache build-base && \ + go get -d -v ./repochecker && \ + go build -v -o repochecker/repochecker ./repochecker && \ + go build -v -buildmode=plugin -o repochecker/epita-rules.so ./repochecker/epita FROM alpine:3.16 RUN apk add --no-cache git -ENTRYPOINT ["/usr/bin/repochecker"] +ENTRYPOINT ["/usr/bin/repochecker", "--checks-plugins=/usr/lib/epita-rules.so"] COPY --from=gobuild /go/src/srs.epita.fr/fic-server/repochecker/repochecker /usr/bin/repochecker +COPY --from=gobuild /go/src/srs.epita.fr/fic-server/repochecker/epita-rules.so /usr/lib/epita-rules.so diff --git a/admin/main.go b/admin/main.go index 7ffe46e9..c0c03dcd 100644 --- a/admin/main.go +++ b/admin/main.go @@ -68,6 +68,7 @@ func main() { gitImporterRemote := "" localImporterSymlink := false baseURL := "/" + checkplugins := sync.CheckPluginList{} // Read paremeters from environment if v, exists := os.LookupEnv("FICOIDC_SECRET"); exists { @@ -119,6 +120,7 @@ func main() { flag.BoolVar(&fic.OptionalDigest, "optionaldigest", fic.OptionalDigest, "Is the digest required when importing files?") flag.BoolVar(&fic.StrongDigest, "strongdigest", fic.StrongDigest, "Are BLAKE2b digests required or is SHA-1 good enough?") flag.BoolVar(&api.IsProductionEnv, "4real", api.IsProductionEnv, "Set this flag when running for a real challenge (it disallows or avoid most of mass user progression deletion)") + flag.Var(&checkplugins, "rules-plugins", "List of libraries containing others rules to checks") flag.Parse() log.SetPrefix("[admin] ") @@ -211,6 +213,15 @@ func main() { os.MkdirAll(api.DashboardDir, 0777) os.MkdirAll(settings.SettingsDir, 0777) + // Load rules plugins + for _, p := range checkplugins { + if err := sync.LoadChecksPlugin(p); err != nil { + log.Fatalf("Unable to load rule plugin %q: %s", p, err.Error()) + } else { + log.Printf("Rules plugin %q successfully loaded", p) + } + } + // Initialize settings and load them if !settings.ExistsSettings(path.Join(settings.SettingsDir, settings.SettingsFile)) { if err = api.ResetSettings(); err != nil { diff --git a/admin/sync/errors.go b/admin/sync/errors.go new file mode 100644 index 00000000..1368f4aa --- /dev/null +++ b/admin/sync/errors.go @@ -0,0 +1,141 @@ +package sync + +import ( + "fmt" + "log" + "path" + + "srs.epita.fr/fic-server/libfic" +) + +var ( + ErrResolutionNotFound = fmt.Errorf("no resolution video or text file found") +) + +type ThemeError struct { + error + ThemeId int64 + ThemePath string + ThemeName string +} + +func NewThemeError(theme *fic.Theme, err error) *ThemeError { + return &ThemeError{ + error: err, + ThemeId: theme.Id, + ThemePath: path.Base(theme.Path), + ThemeName: theme.Name, + } +} + +func (e *ThemeError) Error() string { + return fmt.Sprintf("%s: %s", e.ThemePath, e.error.Error()) +} + +func (e *ThemeError) GetError() error { + return e.error +} + +type ExerciceError struct { + *ThemeError + ExerciceId int64 + ExercicePath string + ExerciceName string +} + +func NewExerciceError(exercice *fic.Exercice, err error, theme ...*fic.Theme) *ExerciceError { + ltheme := len(theme) + if ltheme > 1 { + log.Fatal("Only 1 variadic arg is accepted in NewExerciceError") + return nil + } else if ltheme == 1 { + return &ExerciceError{ + ThemeError: NewThemeError(theme[0], err), + ExerciceId: exercice.Id, + ExercicePath: path.Base(exercice.Path), + ExerciceName: exercice.Title, + } + } else { + return &ExerciceError{ + ThemeError: &ThemeError{error: err}, + ExerciceId: exercice.Id, + ExercicePath: path.Base(exercice.Path), + ExerciceName: exercice.Title, + } + } +} + +func (e *ExerciceError) Error() string { + return fmt.Sprintf("%s: %s", e.ExercicePath, e.ThemeError.error.Error()) +} + +type FileError struct { + *ExerciceError + Filename string +} + +func NewFileError(exercice *fic.Exercice, filename string, err error, theme ...*fic.Theme) *FileError { + return &FileError{ + ExerciceError: NewExerciceError(exercice, err, theme...), + Filename: filename, + } +} + +func (e *FileError) Error() string { + return fmt.Sprintf("%s: file %q: %s", e.ExercicePath, e.Filename, e.ThemeError.error.Error()) +} + +type ChallengeTxtError struct { + *ExerciceError + ChallengeTxtLine uint +} + +func NewChallengeTxtError(exercice *fic.Exercice, line uint, err error, theme ...*fic.Theme) *ChallengeTxtError { + return &ChallengeTxtError{ + ExerciceError: NewExerciceError(exercice, err, theme...), + ChallengeTxtLine: line, + } +} + +func (e *ChallengeTxtError) Error() string { + if e.ChallengeTxtLine != 0 { + return fmt.Sprintf("%s:%d: %s", path.Join(e.ExercicePath, "challenge.txt"), e.ChallengeTxtLine, e.ThemeError.error.Error()) + } else { + return fmt.Sprintf("%s: %s", path.Join(e.ExercicePath, "challenge.txt"), e.ThemeError.error.Error()) + } +} + +type HintError struct { + *ChallengeTxtError + HintId int + HintTitle string +} + +func NewHintError(exercice *fic.Exercice, hint *fic.EHint, line int, err error, theme ...*fic.Theme) *HintError { + return &HintError{ + ChallengeTxtError: NewChallengeTxtError(exercice, 0, err, theme...), + HintId: line + 1, + HintTitle: hint.Title, + } +} + +func (e *HintError) Error() string { + return fmt.Sprintf("%s: hint#%d (%s): %s", path.Join(e.ExercicePath, "challenge.txt"), e.HintId, e.HintTitle, e.ThemeError.error.Error()) +} + +type FlagError struct { + *ChallengeTxtError + FlagId int + FlagTitle string +} + +func NewFlagError(exercice *fic.Exercice, flag *ExerciceFlag, line int, err error, theme ...*fic.Theme) *FlagError { + return &FlagError{ + ChallengeTxtError: NewChallengeTxtError(exercice, 0, err, theme...), + FlagId: line, + } +} + +func (e *FlagError) Error() string { + return fmt.Sprintf("%s: flag#%d: %s", path.Join(e.ExercicePath, "challenge.txt"), e.FlagId, e.ThemeError.error.Error()) +} diff --git a/admin/sync/exercice_defines.go b/admin/sync/exercice_defines.go index 08b679a5..d8fdaa0d 100644 --- a/admin/sync/exercice_defines.go +++ b/admin/sync/exercice_defines.go @@ -90,9 +90,9 @@ func parseExerciceParams(i Importer, exPath string) (p ExerciceParams, md toml.M func getExerciceParams(i Importer, exercice *fic.Exercice) (params ExerciceParams, errs []error) { var err error if params, _, err = parseExerciceParams(i, exercice.Path); err != nil { - errs = append(errs, fmt.Errorf("%q: challenge.txt: %w", path.Base(exercice.Path), err)) + errs = append(errs, NewChallengeTxtError(exercice, 0, err)) } else if len(params.Flags) == 0 && len(params.FlagsUCQ) == 0 && len(params.FlagsMCQ) == 0 { - errs = append(errs, fmt.Errorf("%q: has no flag", path.Base(exercice.Path))) + errs = append(errs, NewChallengeTxtError(exercice, 0, fmt.Errorf("has no flag"))) } else { // Treat legacy UCQ flags as ExerciceFlag for _, flag := range params.FlagsUCQ { diff --git a/admin/sync/exercice_files.go b/admin/sync/exercice_files.go index d68ce1a2..a2bbe541 100644 --- a/admin/sync/exercice_files.go +++ b/admin/sync/exercice_files.go @@ -22,15 +22,15 @@ func BuildFilesListInto(i Importer, exercice *fic.Exercice, into string) (files // Parse DIGESTS.txt if digs, err := GetFileContent(i, path.Join(exercice.Path, into, "DIGESTS.txt")); err != nil { - errs = append(errs, fmt.Errorf("%q: unable to read DIGESTS.txt: %w", path.Base(exercice.Path), err)) + errs = append(errs, NewExerciceError(exercice, fmt.Errorf("unable to read %s: %w", path.Join(into, "DIGESTS.txt"), err))) } else { digests = map[string][]byte{} for nline, d := range strings.Split(digs, "\n") { if dsplt := strings.SplitN(d, " ", 2); len(dsplt) < 2 { - errs = append(errs, fmt.Errorf("%q: unable to parse DIGESTS.txt line %d: invalid format", path.Base(exercice.Path), nline+1)) + errs = append(errs, NewExerciceError(exercice, fmt.Errorf("unable to parse %s line %d: invalid format", path.Join(into, "DIGESTS.txt"), nline+1))) continue } else if hash, err := hex.DecodeString(dsplt[0]); err != nil { - errs = append(errs, fmt.Errorf("%q: unable to parse DIGESTS.txt line %d: %w", path.Base(exercice.Path), nline+1, err)) + errs = append(errs, NewExerciceError(exercice, fmt.Errorf("unable to parse %s line %d: %w", path.Join(into, "DIGESTS.txt"), nline+1, err))) continue } else { digests[strings.TrimFunc(dsplt[1], unicode.IsSpace)] = hash @@ -40,7 +40,7 @@ func BuildFilesListInto(i Importer, exercice *fic.Exercice, into string) (files // Read file list if flist, err := i.listDir(path.Join(exercice.Path, into)); err != nil { - errs = append(errs, err) + errs = append(errs, NewExerciceError(exercice, err)) } else { for _, fname := range flist { if fname == "DIGESTS.txt" || fname == ".gitattributes" { @@ -79,9 +79,9 @@ func CheckExerciceFilesPresence(i Importer, exercice *fic.Exercice) (files []str for _, fname := range flist { if !i.exists(path.Join(exercice.Path, "files", fname)) { - errs = append(errs, fmt.Errorf("%q: unable to read file %q: No such file or directory", path.Base(exercice.Path), fname)) + errs = append(errs, NewFileError(exercice, fname, fmt.Errorf("No such file or directory"))) } else if _, ok := digests[fname]; !ok { - errs = append(errs, fmt.Errorf("%q: unable to import file %q: No digest given", path.Base(exercice.Path), fname)) + errs = append(errs, NewFileError(exercice, fname, fmt.Errorf("unable to import file: No digest given"))) } else { files = append(files, fname) } @@ -89,7 +89,7 @@ func CheckExerciceFilesPresence(i Importer, exercice *fic.Exercice) (files []str for fname := range digests { if !i.exists(path.Join(exercice.Path, "files", fname)) { - errs = append(errs, fmt.Errorf("%q: unable to read file %q: No such file or directory. Check your DIGESTS.txt for legacy entries.", path.Base(exercice.Path), fname)) + errs = append(errs, NewFileError(exercice, fname, fmt.Errorf("unable to read file: No such file or directory. Check your DIGESTS.txt for legacy entries."))) } } @@ -105,10 +105,21 @@ func CheckExerciceFiles(i Importer, exercice *fic.Exercice) (files []string, err w, hash160, hash512 := fic.CreateHashBuffers() if err := GetFile(i, path.Join(exercice.Path, "files", fname), bufio.NewWriter(w)); err != nil { - errs = append(errs, fmt.Errorf("%q: unable to read file %q: %w", path.Base(exercice.Path), fname, err)) + errs = append(errs, NewFileError(exercice, fname, fmt.Errorf("unable to read file: %w", err))) continue } else if _, err := fic.CheckBufferHash(hash160, hash512, digests[fname]); err != nil { - errs = append(errs, fmt.Errorf("%q: %s: %w", path.Base(exercice.Path), fname, err)) + errs = append(errs, NewFileError(exercice, fname, err)) + } else if size, err := getFileSize(i, path.Join(exercice.Path, "files", fname)); err != nil { + errs = append(errs, NewFileError(exercice, fname, err)) + } else { + file := exercice.NewDummyFile(path.Join(exercice.Path, "files", fname), getDestinationFilePath(path.Join(exercice.Path, "files", fname)), (*hash512).Sum(nil), size) + + // Call checks hooks + for _, h := range hooks.fileHooks { + for _, e := range h(file) { + errs = append(errs, NewFileError(exercice, fname, e)) + } + } } files = append(files, fname) @@ -128,19 +139,21 @@ func SyncExerciceFiles(i Importer, exercice *fic.Exercice) (errs []error) { // Import standard files for _, fname := range files { - // Enforce file format - if path.Ext(fname) == "rar" || path.Ext(fname) == "7z" { - errs = append(errs, fmt.Errorf("%q: WARNING %q use a forbidden archive type.", path.Base(exercice.Path), fname)) - } - if f, err := i.importFile(path.Join(exercice.Path, "files", fname), func(filePath string, origin string) (interface{}, error) { return exercice.ImportFile(filePath, origin, digests[fname]) }); err != nil { - errs = append(errs, fmt.Errorf("%q: unable to import file %q: %w", path.Base(exercice.Path), fname, err)) + errs = append(errs, NewFileError(exercice, fname, err)) continue } else if f.(*fic.EFile).Size == 0 { - errs = append(errs, fmt.Errorf("%q: WARNING imported file %q is empty!", path.Base(exercice.Path), fname)) + errs = append(errs, NewFileError(exercice, fname, fmt.Errorf("imported file is empty!"))) + } else { + // Call checks hooks + for _, h := range hooks.fileHooks { + for _, e := range h(f.(*fic.EFile)) { + errs = append(errs, NewFileError(exercice, fname, e)) + } + } } } return diff --git a/admin/sync/exercice_hints.go b/admin/sync/exercice_hints.go index 2a720192..9b73be98 100644 --- a/admin/sync/exercice_hints.go +++ b/admin/sync/exercice_hints.go @@ -19,19 +19,19 @@ import ( type importHint struct { Line int - Hint fic.EHint + Hint *fic.EHint FlagsDeps []int64 } func buildExerciceHints(i Importer, exercice *fic.Exercice) (hints []importHint, errs []error) { params, _, err := parseExerciceParams(i, exercice.Path) if err != nil { - errs = append(errs, fmt.Errorf("%q: challenge.txt: %w", path.Base(exercice.Path), err)) + errs = append(errs, NewChallengeTxtError(exercice, 0, err)) return } for n, hint := range params.Hints { - h := fic.EHint{} + h := &fic.EHint{} if hint.Title == "" { h.Title = fmt.Sprintf("Astuce #%d", n+1) } else { @@ -45,10 +45,10 @@ func buildExerciceHints(i Importer, exercice *fic.Exercice) (hints []importHint, if hint.Filename != "" { if hint.Content != "" { - errs = append(errs, fmt.Errorf("%q: challenge.txt: hint %s (%d): content and filename can't be filled at the same time", path.Base(exercice.Path), hint.Title, n+1)) + errs = append(errs, NewHintError(exercice, h, n, fmt.Errorf("content and filename can't be filled at the same time"))) continue } else if !i.exists(path.Join(exercice.Path, "hints", hint.Filename)) { - errs = append(errs, fmt.Errorf("%q: challenge.txt: hint %s (%d): %s: File not found", path.Base(exercice.Path), hint.Title, n+1, hint.Filename)) + errs = append(errs, NewHintError(exercice, h, n, fmt.Errorf("%q: File not found", hint.Filename))) continue } else { // Handle files as downloadable content @@ -70,20 +70,27 @@ func buildExerciceHints(i Importer, exercice *fic.Exercice) (hints []importHint, // Special format for downloadable hints: $FILES + hexhash + path from FILES/ return "$FILES" + hex.EncodeToString(result512) + strings.TrimPrefix(filePath, fic.FilesDir), nil }); err != nil { - errs = append(errs, fmt.Errorf("%q: unable to import hint file %q: %w", path.Base(exercice.Path), hint.Filename, err)) + errs = append(errs, NewHintError(exercice, h, n, fmt.Errorf("%q: unable to import hint file: %w", hint.Filename, err))) continue } else if s, ok := res.(string); !ok { - errs = append(errs, fmt.Errorf("%q: unable to import hint file %q: invalid string returned as filename", path.Base(exercice.Path), hint.Filename)) + errs = append(errs, NewHintError(exercice, h, n, fmt.Errorf("%q: unable to import hint file: invalid string returned as filename", hint.Filename))) continue } else { h.Content = s } } } else if hint.Content == "" { - errs = append(errs, fmt.Errorf("%q: challenge.txt: hint %s (%d): content and filename can't be empty at the same time", path.Base(exercice.Path), hint.Title, n+1)) + errs = append(errs, NewHintError(exercice, h, n, fmt.Errorf("content and filename can't be empty at the same time"))) continue } else if h.Content, err = ProcessMarkdown(i, fixnbsp(hint.Content), exercice.Path); err != nil { - errs = append(errs, fmt.Errorf("%q: challenge.txt: hint %s (%d): error during markdown formating: %w", path.Base(exercice.Path), hint.Title, n+1, err)) + errs = append(errs, NewHintError(exercice, h, n, fmt.Errorf("error during markdown formating: %w", err))) + } + + // Call checks hooks + for _, hook := range hooks.hintHooks { + for _, e := range hook(h) { + errs = append(errs, NewHintError(exercice, h, n, e)) + } } newHint := importHint{ @@ -120,7 +127,7 @@ func SyncExerciceHints(i Importer, exercice *fic.Exercice, flagsBindings map[int for _, hint := range hints { // Import hint if h, err := exercice.AddHint(hint.Hint.Title, hint.Hint.Content, hint.Hint.Cost); err != nil { - errs = append(errs, fmt.Errorf("%q: hint #%d %s: %w", path.Base(exercice.Path), hint.Line, hint.Hint.Title, err)) + errs = append(errs, NewHintError(exercice, hint.Hint, hint.Line, err)) } else { hintsBindings[hint.Line] = h @@ -128,10 +135,10 @@ func SyncExerciceHints(i Importer, exercice *fic.Exercice, flagsBindings map[int for _, nf := range hint.FlagsDeps { if f, ok := flagsBindings[nf]; ok { if herr := h.AddDepend(f); herr != nil { - errs = append(errs, fmt.Errorf("%q: error hint #%d dependency to flag #%d: %w", path.Base(exercice.Path), hint.Line, nf, herr)) + errs = append(errs, NewHintError(exercice, hint.Hint, hint.Line, fmt.Errorf("error hint dependency to flag #%d: %w", nf, herr))) } } else { - errs = append(errs, fmt.Errorf("%q: error hint #%d dependency to flag #%d: Unexistant flag", path.Base(exercice.Path), hint.Line, nf)) + errs = append(errs, NewHintError(exercice, hint.Hint, hint.Line, fmt.Errorf("error hint dependency to flag #%d: Unexistant flag", nf))) } } } diff --git a/admin/sync/exercice_keys.go b/admin/sync/exercice_keys.go index 3be457d8..b6515b09 100644 --- a/admin/sync/exercice_keys.go +++ b/admin/sync/exercice_keys.go @@ -98,16 +98,16 @@ func getRawKey(input interface{}, validatorRe string, ordered bool, showLines bo func buildLabelFlag(exercice *fic.Exercice, flag ExerciceFlag, flagline int) (f *fic.FlagLabel, errs []error) { if len(flag.Label) == 0 { - errs = append(errs, fmt.Errorf("Label cannot be empty.")) + errs = append(errs, NewFlagError(exercice, &flag, flagline, fmt.Errorf("Label cannot be empty."))) return } if flag.Raw != nil { - errs = append(errs, fmt.Errorf("raw cannot be defined.")) + errs = append(errs, NewFlagError(exercice, &flag, flagline, fmt.Errorf("raw cannot be defined."))) } if len(flag.Choice) != 0 { - errs = append(errs, fmt.Errorf("choices cannot be defined.")) + errs = append(errs, NewFlagError(exercice, &flag, flagline, fmt.Errorf("choices cannot be defined."))) } f = &fic.FlagLabel{ @@ -115,6 +115,13 @@ func buildLabelFlag(exercice *fic.Exercice, flag ExerciceFlag, flagline int) (f Label: flag.Label, Variant: flag.Variant, } + + // Call checks hooks + for _, h := range hooks.flagLabelHooks { + for _, e := range h(f) { + errs = append(errs, NewFlagError(exercice, &flag, flagline, e)) + } + } return } @@ -124,43 +131,35 @@ func buildKeyFlag(exercice *fic.Exercice, flag ExerciceFlag, flagline int, defau } if len(flag.Variant) != 0 { - errs = append(errs, fmt.Errorf("variant is not defined for this kind of flag.")) + errs = append(errs, NewFlagError(exercice, &flag, flagline, fmt.Errorf("variant is not defined for this kind of flag."))) } if flag.Label[0] == '`' { - errs = append(errs, fmt.Errorf("Label should not begin with `.")) + errs = append(errs, NewFlagError(exercice, &flag, flagline, fmt.Errorf("Label should not begin with `."))) flag.Label = flag.Label[1:] } - if (flag.Label[0] == 'Q' || flag.Label[0] == 'q') && (flag.Label[1] == 'U' || flag.Label[1] == 'u') || - (flag.Label[0] == 'W' || flag.Label[0] == 'w') && (flag.Label[1] == 'H' || flag.Label[1] == 'h') { - errs = append(errs, fmt.Errorf("Label should not begin with %s. This seem to be a question. Reword your label as a description of the expected flag, `:` are automatically appended.", flag.Label[0:2])) - flag.Label = flag.Label[1:] - } - - if flag.Label[len(flag.Label)-1] != ')' && flag.Label[len(flag.Label)-1] != '©' && !unicode.IsLetter(rune(flag.Label[len(flag.Label)-1])) && !unicode.IsDigit(rune(flag.Label[len(flag.Label)-1])) { - errs = append(errs, fmt.Errorf("Label should not end with punct (%q). Reword your label as a description of the expected flag, `:` are automatically appended.", flag.Label[len(flag.Label)-1])) - } - raw, prep, terrs := getRawKey(flag.Raw, flag.ValidatorRe, flag.Ordered, flag.ShowLines, flag.Separator) if len(terrs) > 0 { - errs = append(errs, terrs...) + for _, terr := range terrs { + errs = append(errs, NewFlagError(exercice, &flag, flagline, terr)) + } f = nil return } flag.Label = prep + flag.Label if (flag.Type == "text" && !isFullGraphic(strings.Replace(raw, "\n", "", -1))) || (flag.Type != "text" && !isFullGraphic(raw)) { - errs = append(errs, fmt.Errorf("WARNING non-printable characters in flag, is this really expected?")) + errs = append(errs, NewFlagError(exercice, &flag, flagline, fmt.Errorf("WARNING non-printable characters in flag, is this really expected?"))) } hashedFlag, err := fic.ComputeHashedFlag([]byte(raw), !flag.CaseSensitive, flag.NoTrim, validatorRegexp(flag.ValidatorRe), flag.SortReGroups) if err != nil { - errs = append(errs, err) + errs = append(errs, NewFlagError(exercice, &flag, flagline, err)) return } - fl := fic.Flag(&fic.FlagKey{ + fk := &fic.FlagKey{ Type: flag.Type, IdExercice: exercice.Id, Order: int8(flagline), @@ -175,7 +174,16 @@ func buildKeyFlag(exercice *fic.Exercice, flag ExerciceFlag, flagline int, defau Checksum: hashedFlag[:], ChoicesCost: flag.ChoicesCost, BonusGain: flag.BonusGain, - }) + } + + // Call checks hooks + for _, h := range hooks.flagKeyHooks { + for _, e := range h(fk, raw) { + errs = append(errs, NewFlagError(exercice, &flag, flagline, e)) + } + } + + fl := fic.Flag(fk) f = &fl if len(flag.Choice) > 0 || (flag.Type == "ucq" || flag.Type == "radio") { @@ -191,7 +199,9 @@ func buildKeyFlag(exercice *fic.Exercice, flag ExerciceFlag, flagline int, defau for _, choice := range flag.Choice { val, prep, terrs := getRawKey(choice.Value, "", false, false, "") if len(terrs) > 0 { - errs = append(errs, terrs...) + for _, terr := range terrs { + errs = append(errs, NewFlagError(exercice, &flag, flagline, terr)) + } continue } @@ -204,13 +214,22 @@ func buildKeyFlag(exercice *fic.Exercice, flag ExerciceFlag, flagline int, defau val = strings.ToLower(val) } - choices = append(choices, &fic.FlagChoice{ + fc := &fic.FlagChoice{ Label: choice.Label, Value: val, - }) + } + + // Call checks hooks + for _, h := range hooks.flagChoiceHooks { + for _, e := range h(fc) { + errs = append(errs, NewFlagError(exercice, &flag, flagline, e)) + } + } + + choices = append(choices, fc) if val == "true" || val == "false" { - errs = append(errs, fmt.Errorf("value can't be %q, this is not a MCQ, the value has to be meaningful. The value is shown to players as response identifier.", val)) + errs = append(errs, NewFlagError(exercice, &flag, flagline, fmt.Errorf("value can't be %q, this is not a MCQ, the value has to be meaningful. The value is shown to players as response identifier.", val))) } if val == raw || (!flag.CaseSensitive && val == strings.ToLower(raw)) { @@ -218,7 +237,14 @@ func buildKeyFlag(exercice *fic.Exercice, flag ExerciceFlag, flagline int, defau } } if !hasOne { - errs = append(errs, fmt.Errorf("no valid answer defined.")) + errs = append(errs, NewFlagError(exercice, &flag, flagline, fmt.Errorf("no valid answer defined."))) + } + + // Call checks hooks + for _, h := range hooks.flagKeyWithChoicesHooks { + for _, e := range h(fk, raw, choices) { + errs = append(errs, NewFlagError(exercice, &flag, flagline, e)) + } } } return @@ -259,17 +285,17 @@ func buildExerciceFlag(i Importer, exercice *fic.Exercice, flag ExerciceFlag, nl var smin, smax, sstep string err := iface2Number(flag.NumberMin, &smin) if err != nil { - errs = append(errs, fmt.Errorf("%q: flag #%d: min %s.", path.Base(exercice.Path), nline+1, err.Error())) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("min %w", err))) } err = iface2Number(flag.NumberMax, &smax) if err != nil { - errs = append(errs, fmt.Errorf("%q: flag #%d: max %s.", path.Base(exercice.Path), nline+1, err.Error())) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("max %w", err))) } err = iface2Number(flag.NumberStep, &sstep) if err != nil { - errs = append(errs, fmt.Errorf("%q: flag #%d: step %s.", path.Base(exercice.Path), nline+1, err.Error())) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("step %w", err))) } flag.Type = fmt.Sprintf("number,%s,%s,%s", smin, smax, sstep) case "text": @@ -283,23 +309,23 @@ func buildExerciceFlag(i Importer, exercice *fic.Exercice, flag ExerciceFlag, nl case "mcq": flag.Type = "mcq" default: - errs = append(errs, fmt.Errorf("%q: flag #%d: invalid type of flag: should be 'key', 'number', 'text', 'mcq', 'ucq', 'radio' or 'vector'.", path.Base(exercice.Path), nline+1)) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("invalid type of flag: should be 'key', 'number', 'text', 'mcq', 'ucq', 'radio' or 'vector'"))) return } if !strings.HasPrefix(flag.Type, "number") { if flag.NumberMin != nil { - errs = append(errs, fmt.Errorf("%q: flag #%d: property min undefined for this kind of flag: should the type be 'number'.", path.Base(exercice.Path), nline+1)) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("property min undefined for this kind of flag: should the type be 'number'"))) } else if flag.NumberMax != nil { - errs = append(errs, fmt.Errorf("%q: flag #%d: property max undefined for this kind of flag: should the type be 'number'.", path.Base(exercice.Path), nline+1)) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("property max undefined for this kind of flag: should the type be 'number'"))) } else if flag.NumberStep != nil { - errs = append(errs, fmt.Errorf("%q: flag #%d: property step undefined for this kind of flag: should the type be 'number'.", path.Base(exercice.Path), nline+1)) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("property step undefined for this kind of flag: should the type be 'number'"))) } } if len(flag.Help) > 0 { if mdhelp, err := ProcessMarkdown(i, flag.Help, exercice.Path); err != nil { - errs = append(errs, fmt.Errorf("%q: flag #%d: unable to parse property help as Markdown: %w", path.Base(exercice.Path), nline+1, err)) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("unable to parse property help as Markdown: %w", err))) } else { flag.Help = mdhelp[3 : len(mdhelp)-4] } @@ -307,9 +333,7 @@ func buildExerciceFlag(i Importer, exercice *fic.Exercice, flag ExerciceFlag, nl if flag.Type == "label" { addedFlag, berrs := buildLabelFlag(exercice, flag, nline+1) - for _, e := range berrs { - errs = append(errs, fmt.Errorf("%q: flag #%d: %w", path.Base(exercice.Path), nline+1, e)) - } + errs = append(errs, berrs...) if addedFlag != nil { ret = append(ret, importFlag{ Line: nline + 1, @@ -318,9 +342,7 @@ func buildExerciceFlag(i Importer, exercice *fic.Exercice, flag ExerciceFlag, nl } } else if flag.Type == "key" || strings.HasPrefix(flag.Type, "number") || flag.Type == "text" || flag.Type == "ucq" || flag.Type == "radio" || flag.Type == "vector" { addedFlag, choices, berrs := buildKeyFlag(exercice, flag, nline+1, "Flag") - for _, e := range berrs { - errs = append(errs, fmt.Errorf("%q: flag #%d: %w", path.Base(exercice.Path), nline+1, e)) - } + errs = append(errs, berrs...) if addedFlag != nil { ret = append(ret, importFlag{ Line: nline + 1, @@ -340,7 +362,7 @@ func buildExerciceFlag(i Importer, exercice *fic.Exercice, flag ExerciceFlag, nl isJustified := false if len(flag.Variant) != 0 { - errs = append(errs, fmt.Errorf("%q: flag #%d: variant is not defined for this kind of flag.", path.Base(exercice.Path), nline+1)) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("variant is not defined for this kind of flag"))) } if !flag.NoShuffle { @@ -354,7 +376,7 @@ func buildExerciceFlag(i Importer, exercice *fic.Exercice, flag ExerciceFlag, nl if choice.Raw != nil { if hasOne && !isJustified { - errs = append(errs, fmt.Errorf("%q: error MCQ #%d: all true items has to be justified in this MCQ.", path.Base(exercice.Path), nline+1)) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("all true items has to be justified in this MCQ"))) continue } @@ -363,13 +385,13 @@ func buildExerciceFlag(i Importer, exercice *fic.Exercice, flag ExerciceFlag, nl } else if p, ok := choice.Value.(bool); ok { val = p if isJustified { - errs = append(errs, fmt.Errorf("%q: error MCQ #%d: all true items has to be justified in this MCQ.", path.Base(exercice.Path), nline+1)) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("all true items has to be justified in this MCQ"))) continue } } else if choice.Value == nil { val = false } else { - errs = append(errs, fmt.Errorf("%q: error in MCQ %d choice %d: incorrect type for value: %T is not boolean.", path.Base(exercice.Path), nline+1, cid, choice.Value)) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("choice %d: incorrect type for value: %T is not boolean.", cid, choice.Value))) continue } @@ -393,6 +415,13 @@ func buildExerciceFlag(i Importer, exercice *fic.Exercice, flag ExerciceFlag, nl } } + // Call checks hooks + for _, h := range hooks.flagMCQHooks { + for _, e := range h(&addedFlag, addedFlag.Entries) { + errs = append(errs, NewFlagError(exercice, &flag, nline+1, e)) + } + } + ret = append(ret, importFlag{ Line: nline + 1, Flag: &addedFlag, @@ -418,7 +447,7 @@ func buildExerciceFlags(i Importer, exercice *fic.Exercice) (flags map[int64]imp // Ensure flag ID is unique for _, ok := flags[flag.Id]; ok; _, ok = flags[flag.Id] { - errs = append(errs, fmt.Errorf("%q: flag #%d: identifier already used (%d), using a random one.", path.Base(exercice.Path), nline+1, flag.Id)) + errs = append(errs, NewFlagError(exercice, &flag, nline+1, fmt.Errorf("identifier already used (%d), using a random one.", flag.Id))) flag.Id = rand.Int63() } @@ -462,7 +491,7 @@ func CheckExerciceFlags(i Importer, exercice *fic.Exercice, files []string) (rf // Check dependency to flag for _, nf := range flag.FlagsDeps { if _, ok := flags[nf]; !ok { - errs = append(errs, fmt.Errorf("%q: error flag #%d dependency to flag id=%d: id not defined", path.Base(exercice.Path), flag.Line, nf)) + errs = append(errs, NewFlagError(exercice, nil, flag.Line, fmt.Errorf("flag depend on flag id=%d: id not defined", nf))) } } @@ -476,7 +505,7 @@ func CheckExerciceFlags(i Importer, exercice *fic.Exercice, files []string) (rf } } if !found { - errs = append(errs, fmt.Errorf("%q: error flag #%d dependency to %s: No such file", path.Base(exercice.Path), flag.Line, lf)) + errs = append(errs, NewFlagError(exercice, nil, flag.Line, fmt.Errorf("flag depend on %s: No such file", lf))) } } @@ -520,12 +549,12 @@ func SyncExerciceFlags(i Importer, exercice *fic.Exercice) (kmap map[int64]fic.F for _, flagid := range flagids { if flag, ok := flags[flagid]; ok { if addedFlag, err := exercice.AddFlag(flag.Flag); err != nil { - errs = append(errs, fmt.Errorf("%q: error flag #%d: %w", path.Base(exercice.Path), flag.Line, err)) + errs = append(errs, NewFlagError(exercice, nil, flag.Line, err)) } else { if f, ok := addedFlag.(*fic.FlagKey); ok { for _, choice := range flag.Choices { if _, err := f.AddChoice(choice); err != nil { - errs = append(errs, fmt.Errorf("%q: error in flag #%d choice #FIXME: %w", path.Base(exercice.Path), flag.Line, err)) + errs = append(errs, NewFlagError(exercice, nil, flag.Line, fmt.Errorf("choice #FIXME: %w", err))) } } } @@ -535,18 +564,18 @@ func SyncExerciceFlags(i Importer, exercice *fic.Exercice) (kmap map[int64]fic.F // Import dependency to flag for _, nf := range flag.FlagsDeps { if rf, ok := kmap[nf]; !ok { - errs = append(errs, fmt.Errorf("%q: error flag #%d dependency to flag id=%d: id not defined, perhaps not available at time of processing", path.Base(exercice.Path), flag.Line, nf)) + errs = append(errs, NewFlagError(exercice, nil, flag.Line, fmt.Errorf("dependency to flag id=%d: id not defined, perhaps not available at time of processing", nf))) } else if err := addedFlag.AddDepend(rf); err != nil { - errs = append(errs, fmt.Errorf("%q: error flag #%d dependency to id=%d: %w", path.Base(exercice.Path), flag.Line, nf, err)) + errs = append(errs, NewFlagError(exercice, nil, flag.Line, fmt.Errorf("dependency to id=%d: %w", nf, err))) } } // Import dependency to file for _, lf := range flag.FilesDeps { if rf, err := exercice.GetFileByFilename(lf); err != nil { - errs = append(errs, fmt.Errorf("%q: error flag #%d dependency to %s: %w", path.Base(exercice.Path), flag.Line, lf, err)) + errs = append(errs, NewFlagError(exercice, nil, flag.Line, fmt.Errorf("dependency to %s: %w", lf, err))) } else if err := rf.AddDepend(addedFlag); err != nil { - errs = append(errs, fmt.Errorf("%q: error flag #%d dependency to %s: %w", path.Base(exercice.Path), flag.Line, lf, err)) + errs = append(errs, NewFlagError(exercice, nil, flag.Line, fmt.Errorf("dependency to %s: %w", lf, err))) } } } diff --git a/admin/sync/exercices.go b/admin/sync/exercices.go index b60270e5..24baf7da 100644 --- a/admin/sync/exercices.go +++ b/admin/sync/exercices.go @@ -3,7 +3,6 @@ package sync import ( "bytes" "fmt" - "log" "net/http" "net/url" "path" @@ -17,9 +16,6 @@ import ( "srs.epita.fr/fic-server/libfic" ) -// LogMissingResolution logs the absence of resolution.mp4 instead of returning an error. -var LogMissingResolution = false - func fixnbsp(s string) string { return strings.Replace(strings.Replace(strings.Replace(s, " ?", " ?", -1), " !", " !", -1), " :", " :", -1) } @@ -102,7 +98,7 @@ func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]* var err error eid, e.Title, err = parseExerciceDirname(edir) if err != nil { - errs = append(errs, fmt.Errorf("unable to parse exercice directory: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("unable to parse exercice directory: %w", err), theme)) return nil, p, eid, edir, errs } @@ -110,7 +106,7 @@ func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]* if myTitle, err := GetFileContent(i, path.Join(epath, "title.txt")); err == nil { myTitle = strings.TrimSpace(myTitle) if strings.Contains(myTitle, "\n") { - errs = append(errs, fmt.Errorf("title.txt: Title can't contain new lines")) + errs = append(errs, NewExerciceError(e, fmt.Errorf("title.txt: Title can't contain new lines"), theme)) } else { e.Title = myTitle } @@ -128,20 +124,20 @@ func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]* err = fmt.Errorf("Unable to find overview.txt nor overview.md") } if err != nil { - errs = append(errs, fmt.Errorf("overview.txt: %s", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("overview.txt: %s", err), theme)) } else { e.Overview = fixnbsp(e.Overview) var buf bytes.Buffer err := goldmark.Convert([]byte(strings.Split(e.Overview, "\n")[0]), &buf) if err != nil { - errs = append(errs, fmt.Errorf("overview.md: an error occurs during markdown formating of the headline: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("overview.md: an error occurs during markdown formating of the headline: %w", err), theme)) } else { e.Headline = string(buf.Bytes()) } if e.Overview, err = ProcessMarkdown(i, e.Overview, epath); err != nil { - errs = append(errs, fmt.Errorf("overview.md: an error occurs during markdown formating: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("overview.md: an error occurs during markdown formating: %w", err), theme)) } } @@ -153,20 +149,20 @@ func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]* err = fmt.Errorf("Unable to find statement.txt nor statement.md") } if err != nil { - errs = append(errs, fmt.Errorf("statement.md: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("statement.md: %w", err), theme)) } else { if e.Statement, err = ProcessMarkdown(i, fixnbsp(e.Statement), epath); err != nil { - errs = append(errs, fmt.Errorf("statement.md: an error occurs during markdown formating: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("statement.md: an error occurs during markdown formating: %w", err), theme)) } } if i.exists(path.Join(epath, "finished.txt")) { e.Finished, err = GetFileContent(i, path.Join(epath, "finished.txt")) if err != nil { - errs = append(errs, fmt.Errorf("finished.txt: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("finished.txt: %w", err), theme)) } else { if e.Finished, err = ProcessMarkdown(i, e.Finished, epath); err != nil { - errs = append(errs, fmt.Errorf("finished.txt: an error occurs during markdown formating: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("finished.txt: an error occurs during markdown formating: %w", err), theme)) } } } @@ -175,19 +171,19 @@ func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]* var md toml.MetaData p, md, err = parseExerciceParams(i, epath) if err != nil { - errs = append(errs, fmt.Errorf("challenge.txt: %w", err)) + errs = append(errs, NewChallengeTxtError(e, 0, err, theme)) return } // Alert about unknown keys in challenge.txt if len(md.Undecoded()) > 0 { for _, k := range md.Undecoded() { - errs = append(errs, fmt.Errorf("challenge.txt: unknown key %q found, check https://srs.nemunai.re/fic/files/challenge/", k)) + errs = append(errs, NewChallengeTxtError(e, 0, fmt.Errorf("unknown key %q found, check https://srs.nemunai.re/fic/files/challenge/", k), theme)) } } if p.Gain == 0 { - errs = append(errs, fmt.Errorf("challenge.txt: Undefined gain for challenge")) + errs = append(errs, NewChallengeTxtError(e, 0, fmt.Errorf("Undefined gain for challenge"), theme)) } else { e.Gain = p.Gain } @@ -195,11 +191,11 @@ func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]* // Handle dependency if len(p.Dependencies) > 0 { if len(p.Dependencies[0].Theme) > 0 && p.Dependencies[0].Theme != theme.Name { - errs = append(errs, fmt.Errorf("unable to treat dependency to another theme (%q): not implemented.", p.Dependencies[0].Theme)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("unable to treat dependency to another theme (%q): not implemented.", p.Dependencies[0].Theme), theme)) } else { if dmap == nil { if dmap2, err := buildDependancyMap(i, theme); err != nil { - errs = append(errs, fmt.Errorf("unable to build dependency map: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("unable to build dependency map: %w", err), theme)) } else { dmap = &dmap2 } @@ -217,7 +213,7 @@ func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]* for k, _ := range *dmap { dmap_keys = append(dmap_keys, fmt.Sprintf("%d", k)) } - errs = append(errs, fmt.Errorf("Unable to find required exercice dependancy %d (available at time of processing: %s)", p.Dependencies[0].Id, strings.Join(dmap_keys, ","))) + errs = append(errs, NewExerciceError(e, fmt.Errorf("Unable to find required exercice dependancy %d (available at time of processing: %s)", p.Dependencies[0].Id, strings.Join(dmap_keys, ",")), theme)) } } } @@ -230,10 +226,10 @@ func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]* if !i.exists(e.VideoURI) { e.VideoURI = "" } else if size, err := getFileSize(i, e.VideoURI); err != nil { - errs = append(errs, fmt.Errorf("resolution.mp4: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.mp4: %w", err), theme)) e.VideoURI = "" } else if size == 0 { - errs = append(errs, fmt.Errorf("resolution.mp4: The file is empty!")) + errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.mp4: The file is empty!"), theme)) e.VideoURI = "" } else { e.VideoURI = strings.Replace(url.PathEscape(path.Join("$RFILES$", e.VideoURI)), "%2F", "/", -1) @@ -247,24 +243,20 @@ func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]* if i.exists(writeup) { if size, err := getFileSize(i, writeup); err != nil { - errs = append(errs, fmt.Errorf("resolution.md: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.md: %w", err), theme)) } else if size == 0 { - errs = append(errs, fmt.Errorf("resolution.md: The file is empty!")) + errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.md: The file is empty!"), theme)) } else if e.Resolution, err = GetFileContent(i, writeup); err != nil { - errs = append(errs, fmt.Errorf("resolution.md: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.md: %w", err), theme)) } else if e.Resolution, err = ProcessMarkdown(i, e.Resolution, epath); err != nil { - errs = append(errs, fmt.Errorf("resolution.md: error during markdown processing: %w", err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("resolution.md: error during markdown processing: %w", err), theme)) } else { resolutionFound = true } } if !resolutionFound { - if LogMissingResolution { - log.Printf("%q: no resolution video or text file found in %s", edir, epath) - } else { - errs = append(errs, fmt.Errorf("no resolution video or text file found in %s", epath)) - } + errs = append(errs, NewExerciceError(e, ErrResolutionNotFound, theme)) } return @@ -273,30 +265,29 @@ func BuildExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]* // SyncExercice imports new or updates existing given exercice. func SyncExercice(i Importer, theme *fic.Theme, epath string, dmap *map[int64]*fic.Exercice) (e *fic.Exercice, eid int, errs []error) { var err error - var edir string var p ExerciceParams var berrors []error - e, p, eid, edir, berrors = BuildExercice(i, theme, epath, dmap) + e, p, eid, _, berrors = BuildExercice(i, theme, epath, dmap) for _, e := range berrors { - errs = append(errs, fmt.Errorf("%q: %w", edir, e)) + errs = append(errs, e) } if e != nil { // Create or update the exercice err = theme.SaveNamedExercice(e) if err != nil { - errs = append(errs, fmt.Errorf("%q: error on exercice save: %w", edir, err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("error on exercice save: %w", err), theme)) return } // Import eercice tags if _, err := e.WipeTags(); err != nil { - errs = append(errs, fmt.Errorf("%q: Unable to wipe tags: %w", edir, err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("unable to wipe tags: %w", err), theme)) } for _, tag := range p.Tags { if _, err := e.AddTag(tag); err != nil { - errs = append(errs, fmt.Errorf("%q: Unable to add tag: %w", edir, err)) + errs = append(errs, NewExerciceError(e, fmt.Errorf("unable to add tag: %w", err), theme)) return } } diff --git a/admin/sync/hooks.go b/admin/sync/hooks.go new file mode 100644 index 00000000..eed361ab --- /dev/null +++ b/admin/sync/hooks.go @@ -0,0 +1,83 @@ +package sync + +import ( + "fmt" + "plugin" + + "srs.epita.fr/fic-server/libfic" +) + +var hooks = &CheckHooks{} + +type CheckFlagChoiceHook func(*fic.FlagChoice) []error +type CheckFlagKeyHook func(*fic.FlagKey, string) []error +type CheckFlagKeyWithChoicesHook func(*fic.FlagKey, string, []*fic.FlagChoice) []error +type CheckFlagLabelHook func(*fic.FlagLabel) []error +type CheckFlagMCQHook func(*fic.MCQ, []*fic.MCQ_entry) []error +type CheckFileHook func(*fic.EFile) []error +type CheckHintHook func(*fic.EHint) []error + +type CheckHooks struct { + flagChoiceHooks []CheckFlagChoiceHook + flagKeyHooks []CheckFlagKeyHook + flagKeyWithChoicesHooks []CheckFlagKeyWithChoicesHook + flagLabelHooks []CheckFlagLabelHook + flagMCQHooks []CheckFlagMCQHook + fileHooks []CheckFileHook + hintHooks []CheckHintHook +} + +func (h *CheckHooks) RegisterFlagChoiceHook(f CheckFlagChoiceHook) { + h.flagChoiceHooks = append(h.flagChoiceHooks, f) +} + +func (h *CheckHooks) RegisterFlagKeyHook(f CheckFlagKeyHook) { + h.flagKeyHooks = append(h.flagKeyHooks, f) +} + +func (h *CheckHooks) RegisterFlagKeyWithChoicesHook(f CheckFlagKeyWithChoicesHook) { + h.flagKeyWithChoicesHooks = append(h.flagKeyWithChoicesHooks, f) +} + +func (h *CheckHooks) RegisterFlagLabelHook(f CheckFlagLabelHook) { + h.flagLabelHooks = append(h.flagLabelHooks, f) +} + +func (h *CheckHooks) RegisterFlagMCQHook(f CheckFlagMCQHook) { + h.flagMCQHooks = append(h.flagMCQHooks, f) +} + +func (h *CheckHooks) RegisterFileHook(f CheckFileHook) { + h.fileHooks = append(h.fileHooks, f) +} + +func (h *CheckHooks) RegisterHintHook(f CheckHintHook) { + h.hintHooks = append(h.hintHooks, f) +} + +func LoadChecksPlugin(fname string) error { + p, err := plugin.Open(fname) + if err != nil { + return err + } + + register, err := p.Lookup("RegisterChecksHooks") + if err != nil { + return err + } + + register.(func(*CheckHooks))(hooks) + + return nil +} + +type CheckPluginList []string + +func (l *CheckPluginList) String() string { + return fmt.Sprintf("%v", *l) +} + +func (l *CheckPluginList) Set(value string) error { + *l = append(*l, value) + return nil +} diff --git a/admin/sync/themes.go b/admin/sync/themes.go index cb811999..ce7d83ce 100644 --- a/admin/sync/themes.go +++ b/admin/sync/themes.go @@ -120,7 +120,7 @@ func BuildTheme(i Importer, tdir string) (th *fic.Theme, errs []error) { th.URLId = fic.ToURLid(th.Name) if authors, err := getAuthors(i, tdir); err != nil { - errs = append(errs, fmt.Errorf("unable to get AUTHORS.txt: %w", err)) + errs = append(errs, NewThemeError(th, fmt.Errorf("unable to get AUTHORS.txt: %w", err))) return nil, errs } else { // Format authors @@ -137,7 +137,7 @@ func BuildTheme(i Importer, tdir string) (th *fic.Theme, errs []error) { err = fmt.Errorf("unable to find overview.txt nor overview.md") } if err != nil { - errs = append(errs, fmt.Errorf("unable to get theme's overview: %w", err)) + errs = append(errs, NewThemeError(th, fmt.Errorf("unable to get theme's overview: %w", err))) } else { // Split headline from intro ovrvw := strings.Split(fixnbsp(intro), "\n") @@ -149,12 +149,12 @@ func BuildTheme(i Importer, tdir string) (th *fic.Theme, errs []error) { // Format overview (markdown) th.Intro, err = ProcessMarkdown(i, intro, tdir) if err != nil { - errs = append(errs, fmt.Errorf("overview.txt: an error occurs during markdown formating: %w", err)) + errs = append(errs, NewThemeError(th, fmt.Errorf("overview.txt: an error occurs during markdown formating: %w", err))) } var buf bytes.Buffer err := goldmark.Convert([]byte(th.Headline), &buf) if err != nil { - errs = append(errs, fmt.Errorf("overview.txt: an error occurs during markdown formating of the headline: %w", err)) + errs = append(errs, NewThemeError(th, fmt.Errorf("overview.txt: an error occurs during markdown formating of the headline: %w", err))) } else { th.Headline = string(buf.Bytes()) } @@ -165,7 +165,7 @@ func BuildTheme(i Importer, tdir string) (th *fic.Theme, errs []error) { } else if i.exists(path.Join(tdir, "heading.png")) { th.Image = path.Join(tdir, "heading.png") } else { - errs = append(errs, fmt.Errorf("heading.jpg: No such file")) + errs = append(errs, NewThemeError(th, fmt.Errorf("heading.jpg: No such file"))) } if i.exists(path.Join(tdir, "partner.jpg")) { @@ -176,11 +176,11 @@ func BuildTheme(i Importer, tdir string) (th *fic.Theme, errs []error) { if i.exists(path.Join(tdir, "partner.txt")) { if txt, err := GetFileContent(i, path.Join(tdir, "partner.txt")); err != nil { - errs = append(errs, fmt.Errorf("unable to get partner's text: %w", err)) + errs = append(errs, NewThemeError(th, fmt.Errorf("unable to get partner's text: %w", err))) } else { th.PartnerText, err = ProcessMarkdown(i, txt, tdir) if err != nil { - errs = append(errs, fmt.Errorf("partner.txt: an error occurs during markdown formating: %w", err)) + errs = append(errs, NewThemeError(th, fmt.Errorf("partner.txt: an error occurs during markdown formating: %w", err))) } } } @@ -199,7 +199,7 @@ func SyncThemes(i Importer) (errs []error) { for _, tdir := range themes { btheme, berrs := BuildTheme(i, tdir) for _, e := range berrs { - errs = append(errs, fmt.Errorf("%q: %w", tdir, e)) + errs = append(errs, e) } if btheme == nil { @@ -216,7 +216,7 @@ func SyncThemes(i Importer) (errs []error) { btheme.Image = strings.TrimPrefix(filePath, fic.FilesDir) return nil, nil }); err != nil { - errs = append(errs, fmt.Errorf("%q: unable to import heading image: %w", tdir, err)) + errs = append(errs, NewThemeError(btheme, fmt.Errorf("unable to import heading image: %w", err))) } } @@ -226,14 +226,14 @@ func SyncThemes(i Importer) (errs []error) { btheme.PartnerImage = strings.TrimPrefix(filePath, fic.FilesDir) return nil, nil }); err != nil { - errs = append(errs, fmt.Errorf("%q: unable to import partner image: %w", tdir, err)) + errs = append(errs, NewThemeError(btheme, fmt.Errorf("unable to import partner image: %w", err))) } } var theme *fic.Theme if theme, err = fic.GetThemeByPath(btheme.Path); err != nil { if _, err := fic.CreateTheme(btheme); err != nil { - errs = append(errs, fmt.Errorf("%q: an error occurs during add: %w", tdir, err)) + errs = append(errs, NewThemeError(btheme, fmt.Errorf("an error occurs during add: %w", err))) continue } } @@ -241,7 +241,7 @@ func SyncThemes(i Importer) (errs []error) { if !fic.CmpTheme(theme, btheme) { btheme.Id = theme.Id if _, err := btheme.Update(); err != nil { - errs = append(errs, fmt.Errorf("%q: an error occurs during update: %w", tdir, err)) + errs = append(errs, NewThemeError(btheme, fmt.Errorf("an error occurs during update: %w", err))) continue } } diff --git a/libfic/file.go b/libfic/file.go index 16302419..677d526c 100644 --- a/libfic/file.go +++ b/libfic/file.go @@ -44,6 +44,20 @@ type EFile struct { Size int64 `json:"size"` } +// NewDummyFile creates an EFile, without any link to an actual Exercice File. +// It is used to check the file validity +func (e *Exercice) NewDummyFile(origin string, dest string, checksum []byte, size int64) *EFile { + return &EFile{ + Id: 0, + origin: origin, + Path: dest, + IdExercice: e.Id, + Name: path.Base(origin), + Checksum: checksum, + Size: size, + } +} + // GetFiles returns a list of all files living in the database. func GetFiles() ([]*EFile, error) { if rows, err := DBQuery("SELECT id_file, id_exercice, origin, path, name, cksum, size FROM exercice_files"); err != nil { diff --git a/repochecker/epita/files.go b/repochecker/epita/files.go new file mode 100644 index 00000000..ce3791e3 --- /dev/null +++ b/repochecker/epita/files.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + "path" + + "srs.epita.fr/fic-server/libfic" +) + +func EPITACheckFile(file *fic.EFile) (errs []error) { + // Enforce file format + if path.Ext(file.Name) == "rar" || path.Ext(file.Name) == "7z" { + errs = append(errs, fmt.Errorf("this file use a forbidden archive type.")) + } + + return +} diff --git a/repochecker/epita/flags.go b/repochecker/epita/flags.go new file mode 100644 index 00000000..ac886457 --- /dev/null +++ b/repochecker/epita/flags.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + "unicode" + + "srs.epita.fr/fic-server/libfic" +) + +func EPITACheckKeyFlag(flag *fic.FlagKey, raw string) (errs []error) { + if (flag.Label[0] == 'Q' || flag.Label[0] == 'q') && (flag.Label[1] == 'U' || flag.Label[1] == 'u') || + (flag.Label[0] == 'W' || flag.Label[0] == 'w') && (flag.Label[1] == 'H' || flag.Label[1] == 'h') { + errs = append(errs, fmt.Errorf("Label should not begin with %s. This seem to be a question. Reword your label as a description of the expected flag, `:` are automatically appended.", flag.Label[0:2])) + flag.Label = flag.Label[1:] + } + + if flag.Label[len(flag.Label)-1] != ')' && flag.Label[len(flag.Label)-1] != '©' && !unicode.IsLetter(rune(flag.Label[len(flag.Label)-1])) && !unicode.IsDigit(rune(flag.Label[len(flag.Label)-1])) { + errs = append(errs, fmt.Errorf("Label should not end with punct (%q). Reword your label as a description of the expected flag, `:` are automatically appended.", flag.Label[len(flag.Label)-1])) + } + + return +} diff --git a/repochecker/epita/main.go b/repochecker/epita/main.go new file mode 100644 index 00000000..99f3c321 --- /dev/null +++ b/repochecker/epita/main.go @@ -0,0 +1,10 @@ +package main + +import ( + "srs.epita.fr/fic-server/admin/sync" +) + +func RegisterChecksHooks(h *sync.CheckHooks) { + h.RegisterFlagKeyHook(EPITACheckKeyFlag) + h.RegisterFileHook(EPITACheckFile) +} diff --git a/repochecker/main.go b/repochecker/main.go index d73bc60d..b3412fbc 100644 --- a/repochecker/main.go +++ b/repochecker/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" "bytes" + "errors" "flag" "fmt" "io/ioutil" @@ -22,6 +23,7 @@ var ( ignoreBinaryFileUnder = 1500000 skipFileChecks = false skipBinaryFileCheck = false + logMissingResolution = false ) func formatFileSize(size int) string { @@ -145,6 +147,7 @@ func main() { cloudUsername := "fic" cloudPassword := "" localImporterDirectory := "" + checkplugins := sync.CheckPluginList{} // Read paremeters from environment if v, exists := os.LookupEnv("FICCLOUD_URL"); exists { @@ -167,9 +170,10 @@ func main() { flag.BoolVar(&fic.OptionalDigest, "optionaldigest", fic.OptionalDigest, "Is the digest required when importing files?") flag.BoolVar(&fic.StrongDigest, "strongdigest", fic.StrongDigest, "Are BLAKE2b digests required or is SHA-1 good enough?") flag.BoolVar(&skipFileChecks, "skipfiledigests", skipFileChecks, "Don't perform DIGESTS checks on file to speed up the checks") - flag.BoolVar(&sync.LogMissingResolution, "skipresolution", sync.LogMissingResolution, "Don't fail if resolution.mp4 is absent") + flag.BoolVar(&logMissingResolution, "skipresolution", logMissingResolution, "Don't fail if resolution.mp4 is absent") flag.BoolVar(&skipBinaryFileCheck, "skip-binary-file", skipBinaryFileCheck, "In Git-LFS check, don't warn files") flag.IntVar(&ignoreBinaryFileUnder, "skip-binary-files-under", ignoreBinaryFileUnder, "In Git-LFS check, don't warn files under this size") + flag.Var(&checkplugins, "rules-plugins", "List of libraries containing others rules to checks") flag.Parse() // Do not display timestamp @@ -211,6 +215,15 @@ func main() { log.Fatal("No importer nor path given!") } + // Load rules plugins + for _, p := range checkplugins { + if err := sync.LoadChecksPlugin(p); err != nil { + log.Fatalf("Unable to load rule plugin %q: %s", p, err.Error()) + } else { + log.Printf("Rules plugin %q successfully loaded", p) + } + } + // Variable that handles the exit status hasError := false @@ -248,8 +261,17 @@ func main() { for _, edir := range exercices { for _, err := range checkExercice(theme, edir, &dmap) { - nberr += 1 log.Println(err.Error()) + + if logMissingResolution { + if e, ok := err.(*sync.ExerciceError); ok { + if errors.Is(e.GetError(), sync.ErrResolutionNotFound) { + continue + } + } + } + + nberr += 1 } log.Printf("================================== Exercice %q treated\n", edir) }