From 82b15ccb37f79552fed1297dc24532b74d866559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janis=20Daniel=20Da=CC=88hne?= <janis.daehne@informatik.uni-halle.de> Date: Thu, 3 Apr 2025 15:46:53 +0200 Subject: [PATCH] - added method to upload zip files to update do exercise solutions --- src/ClientServer/Config/Constants.cs | 2 +- .../Core/Exercises/DoExerciseController.cs | 411 +++++++++++++++++- 2 files changed, 411 insertions(+), 2 deletions(-) diff --git a/src/ClientServer/Config/Constants.cs b/src/ClientServer/Config/Constants.cs index da10084..f111c21 100644 --- a/src/ClientServer/Config/Constants.cs +++ b/src/ClientServer/Config/Constants.cs @@ -13,7 +13,7 @@ namespace ClientServer.Helpers /// </summary> public static class Constants { - public static string VersionString = "2.25.2"; + public static string VersionString = "2.26.0"; /// <summary> /// this is only set once at program.cs!! diff --git a/src/ClientServer/Controllers/Core/Exercises/DoExerciseController.cs b/src/ClientServer/Controllers/Core/Exercises/DoExerciseController.cs index 8f2bebc..6b4a521 100644 --- a/src/ClientServer/Controllers/Core/Exercises/DoExerciseController.cs +++ b/src/ClientServer/Controllers/Core/Exercises/DoExerciseController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.Contracts; using System.IO; +using System.IO.Compression; using System.Linq; using System.Text; using System.Text.RegularExpressions; @@ -2017,7 +2018,7 @@ namespace ClientServer.Controllers.Core.Exercises if ((await _context.PLangs.FirstOrDefaultAsync(p => p.Id == solutionForBackend.PLangId)) == null) { - await Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.NotFound, "p lang found"))); + await Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.NotFound, "p lang not found"))); return; } @@ -2239,6 +2240,402 @@ namespace ClientServer.Controllers.Core.Exercises ); } + /// <summary> + /// saves or creates the user solution for the given exercise and release + /// + /// we use the first plang of the participation of the user + /// we only know we get files in a zip file, so we have to match the template files by name + /// file extension must be known by the plang + /// + /// see https://stackoverflow.com/questions/77718442/unable-to-send-a-multipart-request-with-an-image-and-json-using-fromform + /// <para></para> + /// IF this method returns an InvalidRequest and a string, then a file name was invalid... the string contains the invalid file name + /// </summary> + /// <param name="generatedCode">the code for the exercise release</param> + /// <param name="shouldClearAllTestResults">let the frontend decide if the code has changed... (e.g. editor has unsaved changes)</param> + /// <param name="updateSolutionData">multipart form data with a zip file as content (see type for file name)</param> + [HttpPost("save/upload/{generatedCode}/{shouldClearAllTestResults}")] + public async Task SaveCodeFilesFromUpload(string generatedCode, bool shouldClearAllTestResults, + [FromForm] SaveFilesFromZip updateSolutionData) + { + if (!await base.IsLoggedIn()) return; + + int userId = GetUserId(); + + if (updateSolutionData == null) + { + await + Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.InvalidRequest, "no files found"))); + return; + } + + + //we use the first lang in the participation as plang + + ExerciseReleaseWithUserAsParticipation oldExerciseReleaseWithUserAsParticipation = + await _context.ExerciseReleaseWithUserAsParticipations + .Where(p => p.ExerciseRelease.GeneratedCode == generatedCode && p.UserId == userId) + .Include(p => p.Solutions) + .ThenInclude(p => p.SolutionFiles) + .ThenInclude(solutionFile => solutionFile.TemplateFile) + .Include(p => p.Solutions) + .ThenInclude(p => p.CustomTestResults) + .Include(p => p.Solutions) + .ThenInclude(p => p.TestResults) + .Include(p => p.ExerciseRelease) + .Include(p => p.ExerciseRelease.Exercise) + .ThenInclude(exercise => exercise.CodeTemplates) + .ThenInclude(p => p.TemplateFiles) + .Include(exerciseReleaseWithUserAsParticipation => + exerciseReleaseWithUserAsParticipation.Solutions) + .ThenInclude(solution => solution.PLang) + .FirstOrDefaultAsync() + ; + + ExerciseRelease release = oldExerciseReleaseWithUserAsParticipation?.ExerciseRelease; + + + if (oldExerciseReleaseWithUserAsParticipation?.ExerciseRelease == null) + { + //this should not happen because when accessing the exercise (getting exercise) then the participation is ensured... + + await Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.NotFound, "release not found"))); + return; + } + + if (release.Exercise == null) + { + await + Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.NotFound, + "exercise was not found"))); + return; + } + + if (release.Exercise.IsPermanentlyLocked) + { + await + Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.InvalidRequest, + "exercise is permanently locked"))); + return; + } + + //check if available working time is up + if (ReleaseHelper.IsTimeUp(oldExerciseReleaseWithUserAsParticipation, + oldExerciseReleaseWithUserAsParticipation.ExerciseRelease, this)) + { + return; + } + + if (oldExerciseReleaseWithUserAsParticipation.ExerciseRelease.Exercise.IsOnlyVisibleToOwner) + { + //then only the owner can access the exercise + if (userId != oldExerciseReleaseWithUserAsParticipation.ExerciseRelease.Exercise.UserId) + { + await + Response.WriteAsync( + Jc.Serialize(new BasicResponse(ResponseCode.InvalidRequest, + "exercise is only accessible by owner"))); + return; + } + } + + + //we don't get info about the plang, use the last one + var lastEditedPLangId = oldExerciseReleaseWithUserAsParticipation.LastEditedPLangId; + + + //use template to get readonly files and check if youser can modify files + CodeTemplate oldCodeTemplateForPLang = release.Exercise.CodeTemplates + .FirstOrDefault(p => p.PLangId == lastEditedPLangId); + + + if (oldCodeTemplateForPLang == null) + { + //no template exists... + await Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.NotFound, "no template found"))); + return; + } + + + //search for solution for the given p lang + Solution oldSolution = + oldExerciseReleaseWithUserAsParticipation.Solutions + .FirstOrDefault( + p => p.PLangId == lastEditedPLangId); + + + if (oldSolution == null) + { + //this should not happen because when accessing the exercise (getting exercise) then the participation (and solution) is ensured... + await Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.NotFound, "solution not found"))); + return; + } + + SolutionDoExerciseFullBase tmpSolution = new SolutionDoExerciseFullBase(); + var tmpSolutionFiles = new List<SolutionFileDoExerciseFullBase>(); + + + //to avoid using wrong files (e.g. osx), ignore files that start with a "." "_" + + var oldSolutionPlang = oldSolution.PLang; + + var validFileExtensions = oldSolutionPlang.FileExtensionsWithDot.Split(",", StringSplitOptions.RemoveEmptyEntries); + + SolutionFileDoExerciseFullBase tmpMainFile = null; + var _filesCount = -1; + using (MemoryStream memoryStream = new MemoryStream()) + { + await updateSolutionData.SolutionFiles.CopyToAsync(memoryStream); + try + { + using (ZipArchive zipArchive = new ZipArchive(memoryStream, ZipArchiveMode.Read)) + { + for (int i = 0; i < zipArchive.Entries.Count; i++) + { + var entry = zipArchive.Entries[i]; + + if (string.IsNullOrEmpty(entry.Name)) + { + //directory + continue; + } + + // / is default but some apps use file system separator + if (entry.FullName.StartsWith(".") + || entry.FullName.StartsWith("_") + || entry.Name.StartsWith(".") + || entry.Name.StartsWith("_") + ) + { + //only use first level, not inside dirs + continue; + } + + if (!validFileExtensions.Any(p => entry.Name.EndsWith(p))) + { + //invalid extension, ignore file + continue; + } + + using StreamReader stream = new StreamReader(entry.Open(), Encoding.Default); + string fileContent = await stream.ReadToEndAsync(); + string fileName = entry.Name.Trim(); + + TemplateFile templateFile = + oldCodeTemplateForPLang.TemplateFiles.FirstOrDefault(p => + p.FileNameWithExtension.Trim() == fileName); + + var tmpSolutionFile = new SolutionFileDoExerciseFullBase() + { + Id = _filesCount--, + Content = fileContent, + FileNameWithExtension = fileName, + TemplateFileId = null, + }; + + var oldSolutionFile = oldSolution.SolutionFiles.FirstOrDefault(p => + p.FileNameWithExtension.Trim() == fileName); + + if (oldSolutionFile != null) + { + //old file + tmpSolutionFile.Id = oldSolutionFile.Id; + //this is ok to set here because OverwriteSolution + //also uses the template from the old solution + templateFile = oldSolutionFile.TemplateFile; + } + + if (templateFile == null) + { + //new file + //check if the user was allowed to create files + if (release.Exercise.CanUserCreateFiles) + { + if (Files.ValidFileName(fileName) == false) + { + //not a valid file name... + await Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.InvalidRequest, + "invalid filename: " + fileName.Trim()))); + return; + } + } + else + { + //ignore file because user was not allowed to create... + continue; + } + + } + else + { + //old file (has template) + tmpSolutionFile.TemplateFileId = templateFile.Id; + tmpSolutionFile.IsContentVisibleForUser = templateFile.IsContentVisibleForUser; + tmpSolutionFile.IsEditableByUser = templateFile.IsEditableByUser; + tmpSolutionFile.UiIsDisplayed = true; + + if (oldCodeTemplateForPLang.MainFileId == templateFile.Id) + { + tmpMainFile = tmpSolutionFile; + } + } + + tmpSolutionFiles.Add(tmpSolutionFile); + } + } + } + catch (Exception e) + { + await Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.InvalidRequest, + "no or invalid zip provided"))); + return; + } + + } + + + if (tmpSolutionFiles.Count == 0) + { + await Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.InvalidRequest, + "no files found"))); + return; + } + + if (tmpMainFile == null) + { + await Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.InvalidRequest, + "main file missing"))); + return; + } + + tmpSolution.MainFileId = tmpMainFile.Id; + tmpSolution.SolutionFiles = tmpSolutionFiles; + + List<Tuple<SolutionFileDoExerciseFullBase, SolutionFile>> fileAssociation = null; + Tuple<Solution, SolutionFile> mainClassFileTuple = null; + + #region handle solution update + + string invalidFileName = OverwriteSolution(tmpSolution, + oldSolution, oldCodeTemplateForPLang, + release, shouldClearAllTestResults, ref mainClassFileTuple, out fileAssociation); + + //invalidate + tmpSolution = null; + + if (invalidFileName != null) + { + await Response.WriteAsync(Jc.Serialize(new BasicResponse(ResponseCode.InvalidRequest, + "invalid filename: " + invalidFileName))); + return; + } + + oldSolution.LastEditingIpAddress = NetworkHelper.GetIpAddress(HttpContext); + + oldExerciseReleaseWithUserAsParticipation.LastUpdatedAt = DateTimeHelper.GetUtcNow(); + + #endregion + + + //check if main file really got set + if (mainClassFileTuple == null) + { + await + Response.WriteAsync( + Jc.Serialize(new BasicResponse(ResponseCode.NotFound, "no main file found"))); + return; + } + + try + { + using (var transaction = _context.Database.BeginTransaction()) + { + try + { + //use a transaction to ensure the main file is already + + //save the solution changes + await _context.SaveChangesAsync(); + + //always save in case user has an invalid solution + mainClassFileTuple.Item1.MainFile = mainClassFileTuple.Item2; + await _context.SaveChangesAsync(); + + transaction.Commit(); + } + catch (Exception ex) + { + transaction.Rollback(); + + await base.HandleDbError(ex); + return; + } + } + } + catch (Exception e) + { + //see https://www.npgsql.org/doc/transactions.html + await base.HandleDbError(e); + return; + } + + string errorMsg = + UserSolutionHelper.ReplaceReadonlyFiles(oldSolution, oldCodeTemplateForPLang, true, false); + + if (string.IsNullOrEmpty(errorMsg) == false) + { + await + Response.WriteAsync( + Jc.Serialize(new BasicResponse(ResponseCode.ServerError, + "could not replace readonly or hiden files"))); + return; + } + + var solutionVersionForFrontend = new SolutionDoExerciseFullBase() + { + UserId = oldSolution.UserId, + ReleaseId = oldSolution.ExerciseReleaseId, + PLangId = oldSolution.PLangId, + LastUpdatedAt = oldSolution.LastUpdatedAt, + // ReSharper disable once PossibleInvalidOperationException //this should be set or checked in OverwriteSolutionVersion or SaveNewSolutionVersion + MainFileId = oldSolution.MainFileId ?? 0, + SolutionFiles = + oldSolution.SolutionFiles.Select( + p => new SolutionFileDoExerciseFullBase() //solutionVersionForBackend + { + Id = p.Id, + Content = p.Content, + UiIsDisplayed = p.IsDisplayed, + //when there is no template file the user created the file and is allowed to edit it + IsEditableByUser = (p.TemplateFile == null || p.TemplateFile.IsEditableByUser), + FileNameWithExtension = p.FileNameWithExtension, + TemplateFileId = p.TemplateFileId, + DisplayIndex = p.DisplayIndex, + IsContentVisibleForUser = p.TemplateFile?.IsContentVisibleForUser ?? true, + }).ToList() + }; + + + var solutionVersionTupleForFrontend = new SolutionTupleFromBackend + { + Solution = solutionVersionForFrontend, + FileIdAssociationMap = fileAssociation.Select(p => new FileIdAssociation() + { + FrontendFileId = p.Item1.Id, + BackendFileId = p.Item2.Id + }).ToList() + }; + + + await + Response.WriteAsync( + Jc.Serialize(new BasicResponseWithData<SolutionTupleFromBackend>(ResponseCode.Ok, + "saved solution version", + solutionVersionTupleForFrontend)) + ); + } + + [HttpPost("reset/{generatedCode}/{pLangId}")] public async Task ResetCode(string generatedCode, int pLangId) @@ -4337,5 +4734,17 @@ namespace ClientServer.Controllers.Core.Exercises #endregion + + #region only for api + + //see https://stackoverflow.com/questions/77718442/unable-to-send-a-multipart-request-with-an-image-and-json-using-fromform + public class SaveFilesFromZip + { + [FromForm(Name = "solutionFiles")] + public IFormFile SolutionFiles { get; set; } + } + + #endregion + #endregion } -- GitLab