diff --git a/backend/LexBoxApi/Controllers/ProjectController.cs b/backend/LexBoxApi/Controllers/ProjectController.cs index 643399f1a..7e21267af 100644 --- a/backend/LexBoxApi/Controllers/ProjectController.cs +++ b/backend/LexBoxApi/Controllers/ProjectController.cs @@ -67,6 +67,24 @@ public async Task> AddLexboxSuffix() .ExecuteUpdateAsync(_ => _.SetProperty(p => p.Code, p => p.Code + "-lexbox")); } + [HttpGet("backupProject/{code}")] + [AdminRequired] + public async Task BackupProject(string code) + { + var filename = await _projectService.BackupProject(new Models.Project.ResetProjectByAdminInput(code)); + if (string.IsNullOrEmpty(filename)) return NotFound(); + var stream = System.IO.File.OpenRead(filename); // Do NOT use "using var stream = ..." as we need to let ASP.NET Core handle the disposal after the download completes + return File(stream, "application/zip", filename); + } + + [HttpPost("resetProject/{code}")] + [AdminRequired] + public async Task ResetProject(string code) + { + await _projectService.ResetProject(new Models.Project.ResetProjectByAdminInput(code)); + return Ok(); + } + [HttpDelete("project/{id}")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs index 9f6ceb6cf..a98f86a54 100644 --- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs +++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs @@ -5,6 +5,7 @@ using LexCore.Entities; using LexCore.Exceptions; using LexCore.ServiceInterfaces; +using LexCore.Utils; using LexData; using LexData.Entities; using Microsoft.EntityFrameworkCore; @@ -153,7 +154,7 @@ public async Task> SoftDeleteProject( if (project.DeletedDate is not null) throw new InvalidOperationException("Project already deleted"); var deletedAt = DateTimeOffset.UtcNow; - var timestamp = deletedAt.ToString("yyyy_MM_dd_HHmmss"); + var timestamp = FileUtils.ToTimestamp(deletedAt); project.DeletedDate = deletedAt; var projectCode = project.Code; project.Code = $"{project.Code}__{timestamp}"; diff --git a/backend/LexBoxApi/Models/Project/ChangeProjectInputs.cs b/backend/LexBoxApi/Models/Project/ChangeProjectInputs.cs index 96944ef01..212ebad46 100644 --- a/backend/LexBoxApi/Models/Project/ChangeProjectInputs.cs +++ b/backend/LexBoxApi/Models/Project/ChangeProjectInputs.cs @@ -5,3 +5,5 @@ public record ChangeProjectNameInput(Guid ProjectId, string Name); public record ChangeProjectDescriptionInput(Guid ProjectId, string Description); public record DeleteUserByAdminOrSelfInput(Guid UserId); + +public record ResetProjectByAdminInput(string Code); diff --git a/backend/LexBoxApi/Services/HgService.cs b/backend/LexBoxApi/Services/HgService.cs index 06068f2ef..2ac906ff9 100644 --- a/backend/LexBoxApi/Services/HgService.cs +++ b/backend/LexBoxApi/Services/HgService.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using System.IO; +using System.IO.Compression; using System.Text.Json; using System.Text.Json.Nodes; using LexBoxApi.Config; @@ -5,6 +8,7 @@ using LexCore.Entities; using LexCore.Exceptions; using LexCore.ServiceInterfaces; +using LexCore.Utils; using Microsoft.Extensions.Options; using Path = System.IO.Path; @@ -49,6 +53,42 @@ public async Task DeleteRepo(string code) await Task.Run(() => Directory.Delete(Path.Combine(_options.Value.RepoPath, code), true)); } + public async Task BackupRepo(string code) + { + string repoPath = Path.Combine(_options.Value.RepoPath, code); + var repoDir = new DirectoryInfo(repoPath); + if (!repoDir.Exists) + { + return null; // Which controller will turn into HTTP 404 + } + string tempPath = Path.GetTempPath(); + string timestamp = FileUtils.ToTimestamp(DateTime.UtcNow); + string baseName = $"backup-{code}-{timestamp}.zip"; + string filename = Path.Join(tempPath, baseName); + // TODO: Check if a backup has been taken within the past 30 minutes, and return that backup instead of making a new one + // This would allow resuming an interrupted download + await Task.Run(() => ZipFile.CreateFromDirectory(repoPath, filename)); + return filename; + } + + public async Task ResetRepo(string code) + { + string timestamp = FileUtils.ToTimestamp(DateTimeOffset.UtcNow); + await SoftDeleteRepo(code, $"{timestamp}__reset"); + await InitRepo(code); + } + + public async Task RevertRepo(string code, string revHash) + { + // Steps: + // 1. Rename repo to repo-backup-date (verifying first that it does not exist, adding -NNN at the end (001, 002, 003) if it does) + // 2. Make empty directory (NOT a repo yet) with this project code + // 3. Clone repo-backup-date-NNN into empty directory, passing "-r revHash" param + // 4. Copy .hg/hgrc from backup dir, overwriting the one in the cloned dir + // + // Will need an SSH key as a k8s secret, put it into authorized_keys on the hgweb side so that lexbox can do "ssh hgweb hg clone ..." + } + public async Task SoftDeleteRepo(string code, string deletedRepoSuffix) { var deletedRepoName = $"{code}__{deletedRepoSuffix}"; diff --git a/backend/LexBoxApi/Services/ProjectService.cs b/backend/LexBoxApi/Services/ProjectService.cs index f6260c5c4..66fd52dff 100644 --- a/backend/LexBoxApi/Services/ProjectService.cs +++ b/backend/LexBoxApi/Services/ProjectService.cs @@ -39,6 +39,17 @@ public async Task CreateProject(CreateProjectInput input, Guid userId) return projectId; } + public async Task BackupProject(ResetProjectByAdminInput input) + { + var backupFile = await _hgService.BackupRepo(input.Code); + return backupFile; + } + + public async Task ResetProject(ResetProjectByAdminInput input) + { + await _hgService.ResetRepo(input.Code); + } + public async Task UpdateLastCommit(string projectCode) { var lastCommitFromHg = await _hgService.GetLastCommitTimeFromHg(projectCode); diff --git a/backend/LexCore/ServiceInterfaces/IHgService.cs b/backend/LexCore/ServiceInterfaces/IHgService.cs index 81d8946d2..6b789ffb9 100644 --- a/backend/LexCore/ServiceInterfaces/IHgService.cs +++ b/backend/LexCore/ServiceInterfaces/IHgService.cs @@ -9,4 +9,6 @@ public interface IHgService Task GetChangesets(string projectCode); Task DeleteRepo(string code); Task SoftDeleteRepo(string code, string deletedRepoSuffix); + Task BackupRepo(string code); + Task ResetRepo(string code); } diff --git a/backend/LexCore/Utils/FileUtils.cs b/backend/LexCore/Utils/FileUtils.cs new file mode 100644 index 000000000..20cf58d6e --- /dev/null +++ b/backend/LexCore/Utils/FileUtils.cs @@ -0,0 +1,13 @@ +using System.Globalization; + +namespace LexCore.Utils; + +public static class FileUtils +{ + public static string ToTimestamp(DateTimeOffset dateTime) + { + var timestamp = dateTime.ToString(DateTimeFormatInfo.InvariantInfo.SortableDateTimePattern); + // make it file-system friendly + return timestamp.Replace(':', '-'); + } +} diff --git a/frontend/src/lib/app.postcss b/frontend/src/lib/app.postcss index f0c4596d4..20cddf68f 100644 --- a/frontend/src/lib/app.postcss +++ b/frontend/src/lib/app.postcss @@ -39,6 +39,11 @@ form { .form-control { @apply mb-2; + + label:last-child { + /* Essentially removes the margin that we added to the form-control */ + @apply pb-0; + } } } @@ -51,6 +56,10 @@ form { @apply btn-error; } +.reset-modal .btn[type='submit'] { + @apply btn-accent; +} + .menu { .text-error { @apply !text-error; diff --git a/frontend/src/lib/components/MoreSettings.svelte b/frontend/src/lib/components/MoreSettings.svelte index 37d0e98f7..0d6b8803d 100644 --- a/frontend/src/lib/components/MoreSettings.svelte +++ b/frontend/src/lib/components/MoreSettings.svelte @@ -7,7 +7,7 @@
{$t('more_settings.title')}
-
+
diff --git a/frontend/src/lib/forms/Checkbox.svelte b/frontend/src/lib/forms/Checkbox.svelte index befeb8ef6..cdbacdee9 100644 --- a/frontend/src/lib/forms/Checkbox.svelte +++ b/frontend/src/lib/forms/Checkbox.svelte @@ -1,14 +1,17 @@
-
diff --git a/frontend/src/lib/forms/FormField.svelte b/frontend/src/lib/forms/FormField.svelte index 764e42da9..50573f579 100644 --- a/frontend/src/lib/forms/FormField.svelte +++ b/frontend/src/lib/forms/FormField.svelte @@ -42,7 +42,7 @@
diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index c6200519f..7e081caf1 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -152,8 +152,19 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia "change_role_modal": { "title": "Choose role for {name}" }, + "reset_project_modal": { + "title": "Reset project", + "submit": "Reset project", + "download_button": "Download backup file", + "confirm_downloaded": "I confirm that I have downloaded the backup file and verified that it works. I am ready to completely reset the repository history.", + "confirm_downloaded_error": "Please check the box to confirm you have downloaded the backup file", + "confirm_project_code": "Enter project code to confirm reset", + "confirm_project_code_error": "Please type the project code to confirm reset", + "reset_project_notification": "Successfully reset project {code}" + }, "notifications": { "role_change": "Project role of {name} set to {role}.", + "reset_project": "Project {code} has been reset.", "user_delete": "{name} has been removed.", "rename_project": "Project name set to {name}.", "describe": "Project description has been updated.", diff --git a/frontend/src/lib/icons/CircleArrowIcon.svelte b/frontend/src/lib/icons/CircleArrowIcon.svelte new file mode 100644 index 000000000..d4cc6ece6 --- /dev/null +++ b/frontend/src/lib/icons/CircleArrowIcon.svelte @@ -0,0 +1 @@ + diff --git a/frontend/src/lib/icons/index.ts b/frontend/src/lib/icons/index.ts index faa5ca415..1496358d7 100644 --- a/frontend/src/lib/icons/index.ts +++ b/frontend/src/lib/icons/index.ts @@ -1,5 +1,6 @@ import AdminIcon from './AdminIcon.svelte'; import AuthenticatedUserIcon from './AuthenticatedUserIcon.svelte'; +import CircleArrowIcon from './CircleArrowIcon.svelte'; import HamburgerIcon from './HamburgerIcon.svelte'; import HomeIcon from './HomeIcon.svelte' import LogoutIcon from './LogoutIcon.svelte'; @@ -9,6 +10,7 @@ import TrashIcon from './TrashIcon.svelte'; export { AuthenticatedUserIcon, + CircleArrowIcon, HamburgerIcon, LogoutIcon, UserAddOutline, diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte index 690d38756..5962acb6b 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte @@ -13,10 +13,11 @@ import { _changeProjectDescription, _changeProjectName, _deleteProjectUser, type ProjectUser } from './+page'; import AddProjectMember from './AddProjectMember.svelte'; import ChangeMemberRoleModal from './ChangeMemberRoleModal.svelte'; - import { TrashIcon } from '$lib/icons'; + import { CircleArrowIcon, TrashIcon } from '$lib/icons'; import { notifySuccess, notifyWarning } from '$lib/notify'; import { DialogResponse } from '$lib/components/modals'; import type { ErrorMessage } from '$lib/forms'; + import ResetProjectModal from './ResetProjectModal.svelte'; import Dropdown from '$lib/components/Dropdown.svelte'; import { FormField } from '$lib/forms'; import IconButton from '$lib/components/IconButton.svelte'; @@ -57,6 +58,11 @@ } } + let resetProjectModal: ResetProjectModal; + async function resetProject(): Promise { + await resetProjectModal.open(_project.code); + } + let removeUserModal: DeleteModal; let userToDelete: ProjectUser | undefined; async function deleteProjectUser(projectUser: ProjectUser): Promise { @@ -265,7 +271,6 @@
-

{$t('project_page.history')} @@ -287,6 +292,12 @@ + + + + {/if} diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/ResetProjectModal.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/ResetProjectModal.svelte new file mode 100644 index 000000000..7139b1488 --- /dev/null +++ b/frontend/src/routes/(authenticated)/project/[project_code]/ResetProjectModal.svelte @@ -0,0 +1,82 @@ + + + + +

+ + {$t('title')} + + + + + {$t('submit')} + + + +