server/admin/sync/exercice_files.go

456 lines
14 KiB
Go

package sync
import (
"compress/gzip"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path"
"strings"
"unicode"
"github.com/gin-gonic/gin"
"go.uber.org/multierr"
"srs.epita.fr/fic-server/libfic"
)
type remoteFileDomainWhitelist []string
func (l *remoteFileDomainWhitelist) String() string {
return fmt.Sprintf("%v", *l)
}
func (l *remoteFileDomainWhitelist) Set(value string) error {
*l = append(*l, value)
return nil
}
var RemoteFileDomainWhitelist remoteFileDomainWhitelist
func isURLAllowed(in string) bool {
if len(RemoteFileDomainWhitelist) == 0 {
return true
}
u, err := url.Parse(in)
if err != nil {
return false
}
for _, t := range RemoteFileDomainWhitelist {
if t == u.Host {
return true
}
}
return false
}
func BuildFilesListInto(i Importer, exercice *fic.Exercice, into string) (files []string, digests map[string][]byte, errs error) {
// If no files directory, don't display error
if !i.Exists(path.Join(exercice.Path, into)) {
return
}
// Parse DIGESTS.txt
if digs, err := GetFileContent(i, path.Join(exercice.Path, into, "DIGESTS.txt")); err != nil {
errs = multierr.Append(errs, NewExerciceError(exercice, fmt.Errorf("unable to read %s: %w", path.Join(into, "DIGESTS.txt"), err)))
} else if len(digs) > 0 {
digests = map[string][]byte{}
for nline, d := range strings.Split(digs, "\n") {
if dsplt := strings.SplitN(d, " ", 2); len(dsplt) < 2 {
errs = multierr.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 = multierr.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
}
}
}
// Read file list
if flist, err := i.ListDir(path.Join(exercice.Path, into)); err != nil {
errs = multierr.Append(errs, NewExerciceError(exercice, err))
} else {
for _, fname := range flist {
if fname == "DIGESTS.txt" || fname == ".gitattributes" {
continue
}
if matched, _ := path.Match("*.[0-9][0-9]", fname); matched {
fname = fname[:len(fname)-3]
} else if matched, _ := path.Match("*[0-9][0-9]", fname); matched {
fname = fname[:len(fname)-2]
} else if matched, _ := path.Match("*_MERGED", fname); matched {
continue
}
fileFound := false
for _, f := range files {
if fname == f {
fileFound = true
break
}
}
if !fileFound {
files = append(files, fname)
}
}
}
// Complete with remote file names
if paramsFiles, err := GetExerciceFilesParams(i, exercice); err == nil {
for _, pf := range paramsFiles {
if pf.URL != "" {
found := false
for _, file := range files {
if file == pf.Filename {
found = true
break
}
}
if !found {
files = append(files, pf.Filename)
}
}
}
}
return
}
// CheckExerciceFilesPresence limits remote checks to presence, don't get it to check digest.
func CheckExerciceFilesPresence(i Importer, exercice *fic.Exercice) (files []string, errs error) {
flist, digests, berrs := BuildFilesListInto(i, exercice, "files")
errs = multierr.Append(errs, berrs)
paramsFiles, _ := GetExerciceFilesParams(i, exercice)
for _, fname := range flist {
if !i.Exists(path.Join(exercice.Path, "files", fname)) && !i.Exists(path.Join(exercice.Path, "files", fname+".00")) {
// File not found locally, is this a remote file?
if pf, exists := paramsFiles[fname]; !exists || pf.URL == "" {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("No such file or directory")))
continue
} else if !isURLAllowed(pf.URL) {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("URL hostname is not whitelisted")))
continue
} else {
resp, err := http.Head(pf.URL)
if err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, err))
continue
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("Unexpected status code for the HTTP response: %d %s", resp.StatusCode, resp.Status)))
continue
}
}
}
if _, ok := digests[fname]; !ok {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("unable to import file: No digest given")))
} else {
files = append(files, fname)
}
}
for fname := range digests {
if !i.Exists(path.Join(exercice.Path, "files", fname)) && !i.Exists(path.Join(exercice.Path, "files", fname+".gz")) && !i.Exists(path.Join(exercice.Path, "files", fname+".00")) && !i.Exists(path.Join(exercice.Path, "files", fname+".gz.00")) {
if pf, exists := paramsFiles[fname]; !exists || pf.URL == "" {
if pf, exists := paramsFiles[fname+".gz"]; !exists || pf.URL == "" {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("unable to read file: No such file or directory. Check your DIGESTS.txt for legacy entries.")))
}
}
}
}
return
}
// CheckExerciceFiles checks that remote files have the right digest.
func CheckExerciceFiles(i Importer, exercice *fic.Exercice, exceptions *CheckExceptions) (files []string, errs error) {
flist, digests, berrs := BuildFilesListInto(i, exercice, "files")
errs = multierr.Append(errs, berrs)
paramsFiles, err := GetExerciceFilesParams(i, exercice)
if err != nil {
errs = multierr.Append(errs, NewChallengeTxtError(exercice, 0, err))
}
for _, fname := range flist {
dest := path.Join(exercice.Path, "files", fname)
if pf, exists := paramsFiles[fname]; exists && pf.URL != "" {
if li, ok := i.(LocalImporter); ok {
errs = multierr.Append(errs, DownloadExerciceFile(paramsFiles[fname], li.GetLocalPath(dest), exercice, false))
} else {
errs = multierr.Append(errs, DownloadExerciceFile(paramsFiles[fname], dest, exercice, false))
}
}
if fd, closer, err := GetFile(i, dest); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("unable to read file: %w", err)))
continue
} else {
defer closer()
hash160, hash512 := fic.CreateHashBuffers(fd)
if _, err := fic.CheckBufferHash(hash160, hash512, digests[fname]); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, err))
} else if size, err := GetFileSize(i, path.Join(exercice.Path, "files", fname)); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, err))
} else {
var digest_shown []byte
if strings.HasSuffix(fname, ".gz") {
if d, exists := digests[strings.TrimSuffix(fname, ".gz")]; exists {
digest_shown = d
// Check that gunzipped file digest is correct
if fd, closer, err := GetFile(i, path.Join(exercice.Path, "files", fname)); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("unable to read file: %w", err)))
continue
} else if gunzipfd, err := gzip.NewReader(fd); err != nil {
closer()
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("unable to gunzip file: %w", err)))
continue
} else {
defer gunzipfd.Close()
defer closer()
hash160_inflate, hash512_inflate := fic.CreateHashBuffers(gunzipfd)
if _, err := fic.CheckBufferHash(hash160_inflate, hash512_inflate, digest_shown); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, strings.TrimSuffix(fname, ".gz"), err))
}
}
}
}
disclaimer := ""
if f, exists := paramsFiles[fname]; exists {
// Call checks hooks
for _, hk := range hooks.mdTextHooks {
for _, err := range multierr.Errors(hk(f.Disclaimer, exercice.Language, exceptions)) {
errs = multierr.Append(errs, NewFileError(exercice, fname, err))
}
}
if disclaimer, err = ProcessMarkdown(i, fixnbsp(f.Disclaimer), exercice.Path); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("error during markdown formating of disclaimer: %w", err)))
}
}
file := exercice.NewDummyFile(path.Join(exercice.Path, "files", fname), GetDestinationFilePath(path.Join(exercice.Path, "files", fname), nil), (*hash512).Sum(nil), digest_shown, disclaimer, size)
// Call checks hooks
for _, h := range hooks.fileHooks {
for _, e := range multierr.Errors(h(file, exercice, exceptions)) {
errs = multierr.Append(errs, NewFileError(exercice, fname, e))
}
}
}
}
files = append(files, fname)
}
return
}
// DownloadExerciceFile is responsible to fetch remote files.
func DownloadExerciceFile(pf ExerciceFile, dest string, exercice *fic.Exercice, force bool) (errs error) {
if st, err := os.Stat(dest); !force && !os.IsNotExist(err) {
resp, err := http.Head(pf.URL)
if err == nil && resp.ContentLength == st.Size() {
return
}
}
if !isURLAllowed(pf.URL) {
errs = multierr.Append(errs, NewFileError(exercice, path.Base(dest), fmt.Errorf("URL hostname is not whitelisted")))
return
}
log.Println("Download exercice file: ", pf.URL)
resp, err := http.Get(pf.URL)
if err != nil {
errs = multierr.Append(errs, NewFileError(exercice, path.Base(dest), err))
return
}
defer resp.Body.Close()
if err = os.MkdirAll(path.Dir(dest), 0751); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, path.Base(dest), err))
return
}
// Write file
var fdto *os.File
if fdto, err = os.Create(dest); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, path.Base(dest), err))
return
} else {
defer fdto.Close()
_, err = io.Copy(fdto, resp.Body)
if err != nil {
errs = multierr.Append(errs, NewFileError(exercice, path.Base(dest), err))
return
}
}
return
}
// SyncExerciceFiles reads the content of files/ directory and import it as EFile for the given challenge.
// It takes care of DIGESTS.txt and ensure imported files match.
func SyncExerciceFiles(i Importer, exercice *fic.Exercice, exceptions *CheckExceptions) (errs error) {
if _, err := exercice.WipeFiles(); err != nil {
errs = multierr.Append(errs, err)
}
paramsFiles, err := GetExerciceFilesParams(i, exercice)
if err != nil {
errs = multierr.Append(errs, NewChallengeTxtError(exercice, 0, err))
return
}
files, digests, berrs := BuildFilesListInto(i, exercice, "files")
errs = multierr.Append(errs, berrs)
// Import standard files
for _, fname := range files {
actionAfterImport := func(filePath string, origin string) (interface{}, error) {
var digest_shown []byte
if strings.HasSuffix(fname, ".gz") {
if d, exists := digests[strings.TrimSuffix(fname, ".gz")]; exists {
digest_shown = d
}
}
published := true
disclaimer := ""
if f, exists := paramsFiles[fname]; exists {
published = !f.Hidden
// Call checks hooks
for _, hk := range hooks.mdTextHooks {
for _, err := range multierr.Errors(hk(f.Disclaimer, exercice.Language, exceptions)) {
errs = multierr.Append(errs, NewFileError(exercice, fname, err))
}
}
if disclaimer, err = ProcessMarkdown(i, fixnbsp(f.Disclaimer), exercice.Path); err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("error during markdown formating of disclaimer: %w", err)))
}
}
return exercice.ImportFile(filePath, origin, digests[fname], digest_shown, disclaimer, published)
}
var f interface{}
if pf, exists := paramsFiles[fname]; exists && pf.URL != "" {
dest := GetDestinationFilePath(pf.URL, &pf.Filename)
if _, err := os.Stat(dest); !os.IsNotExist(err) {
if d, err := actionAfterImport(dest, pf.URL); err == nil {
f = d
}
}
if f == nil {
errs = multierr.Append(errs, DownloadExerciceFile(paramsFiles[fname], dest, exercice, false))
f, err = actionAfterImport(dest, pf.URL)
}
} else {
f, err = i.importFile(path.Join(exercice.Path, "files", fname), actionAfterImport)
}
if err != nil {
errs = multierr.Append(errs, NewFileError(exercice, fname, err))
continue
}
if f.(*fic.EFile).Size == 0 {
errs = multierr.Append(errs, NewFileError(exercice, fname, fmt.Errorf("imported file is empty!")))
} else {
file := f.(*fic.EFile)
// Call checks hooks
for _, h := range hooks.fileHooks {
for _, e := range multierr.Errors(h(file, exercice, exceptions)) {
errs = multierr.Append(errs, NewFileError(exercice, fname, e))
}
}
// Create empty non-gziped file for nginx gzip-static module
if len(file.ChecksumShown) > 0 && strings.HasSuffix(file.Name, ".gz") {
file.Name = strings.TrimSuffix(file.Name, ".gz")
file.Path = strings.TrimSuffix(file.Path, ".gz")
fd, err := os.Create(path.Join(fic.FilesDir, file.Path))
if err == nil {
fd.Close()
_, err = file.Update()
if err != nil {
log.Println("Unable to update file after .gz removal:", err.Error())
}
} else {
log.Printf("Unable to create %q: %s", file.Path, err)
}
}
}
}
return
}
// ApiGetRemoteExerciceFiles is an accessor to remote exercice files list.
func ApiGetRemoteExerciceFiles(c *gin.Context) {
theme, exceptions, errs := BuildTheme(GlobalImporter, c.Params.ByName("thid"))
if theme != nil {
exercice, _, _, _, _, errs := BuildExercice(GlobalImporter, theme, path.Join(theme.Path, c.Params.ByName("exid")), nil, exceptions)
if exercice != nil {
files, digests, errs := BuildFilesListInto(GlobalImporter, exercice, "files")
if files != nil {
var ret []*fic.EFile
for _, fname := range files {
fPath := path.Join(exercice.Path, "files", fname)
fSize, _ := GetFileSize(GlobalImporter, fPath)
ret = append(ret, &fic.EFile{
Path: fPath,
Name: fname,
Checksum: digests[fname],
Size: fSize,
})
}
c.JSON(http.StatusOK, ret)
} else {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Errorf("%q", errs)})
return
}
} else {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Errorf("%q", errs)})
return
}
} else {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": fmt.Errorf("%q", errs)})
return
}
}