Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reset repo feature #211

Merged
merged 22 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4967527
First steps towards the reset-project feature
rmunn Jul 27, 2023
647e126
Modal now submits properly
rmunn Jul 27, 2023
62b655c
Better look for reset project modal
rmunn Aug 15, 2023
ef30507
Hook up project-reset button
rmunn Aug 15, 2023
cd92c84
Improve look of reset-project button
rmunn Aug 23, 2023
aa22686
Remove border around reset-project button
rmunn Aug 23, 2023
b881004
Merge branch 'develop' into feature/reset-repo
rmunn Aug 24, 2023
b493926
Use new SoftDeleteRepo in ResetRepo code
rmunn Aug 24, 2023
e3cb57c
Add translated text for project-reset notification
rmunn Aug 24, 2023
9c6f92e
Use AdminContent component instead of #if isAdmin
rmunn Aug 24, 2023
d22bfe6
No need for ?. when we know the project is defined
rmunn Aug 24, 2023
c7a22e2
Use non-nullable _project to silence linter
rmunn Aug 24, 2023
0e4d7a4
Address some review comments
rmunn Sep 1, 2023
faa4046
Refactor output of reset project API
rmunn Sep 1, 2023
4dd151f
Merge branch 'develop' into feature/reset-repo
rmunn Sep 5, 2023
b159d10
Remove ResetProjectByAdminOutput class
rmunn Sep 5, 2023
92786bc
Edit Checkbox component to have label on right
rmunn Sep 7, 2023
f7fb3ca
Rewrite ResetProjectModal to use FormDialog
rmunn Sep 7, 2023
fe46c85
Handle case where project had no repo dir
rmunn Sep 7, 2023
dc339ce
No need for target="_blank" in the backup link
rmunn Sep 7, 2023
e7502f1
Review fixups
myieye Sep 7, 2023
320c22e
Merge remote-tracking branch 'origin/develop' into feature/reset-repo
myieye Sep 7, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions backend/LexBoxApi/Controllers/ProjectController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ public async Task<ActionResult<int>> AddLexboxSuffix()
.ExecuteUpdateAsync(_ => _.SetProperty(p => p.Code, p => p.Code + "-lexbox"));
}

[HttpGet("backupProject/{code}")]
[AdminRequired]
public async Task<IActionResult> BackupProject(string code)
{
var filename = await _projectService.BackupProject(new Models.Project.ResetProjectByAdminInput(code));
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<ActionResult<string>> ResetProject(string code)
{
return await _projectService.ResetProject(new Models.Project.ResetProjectByAdminInput(code));
hahn-kev marked this conversation as resolved.
Show resolved Hide resolved
}

[HttpDelete("project/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand Down
2 changes: 2 additions & 0 deletions backend/LexBoxApi/Models/Project/ChangeProjectInputs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
35 changes: 35 additions & 0 deletions backend/LexBoxApi/Services/HgService.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -39,6 +42,38 @@ public async Task DeleteRepo(string code)
await Task.Run(() => Directory.Delete(Path.Combine(_options.Value.RepoPath, code), true));
}

public async Task<string> BackupRepo(string code)
{
string tempPath = Path.GetTempPath();
string timestamp = DateTime.UtcNow.ToString(DateTimeFormatInfo.InvariantInfo.SortableDateTimePattern).Replace(':', '-');
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(Path.Combine(_options.Value.RepoPath, code), filename));
rmunn marked this conversation as resolved.
Show resolved Hide resolved
return filename;
}

public async Task<string> ResetRepo(string code)
{
string timestamp = DateTimeOffset.UtcNow.ToString("yyyy_MM_dd_HHmmss");
// TODO: Make that "yyyy_MM_dd_HHmmss" string a constant somewhere, then reference it here and in ProjectMutations.SoftDeleteProject
myieye marked this conversation as resolved.
Show resolved Hide resolved
await SoftDeleteRepo(code, timestamp);
myieye marked this conversation as resolved.
Show resolved Hide resolved
await InitRepo(code);
return backupPath;
}

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}";
Expand Down
12 changes: 12 additions & 0 deletions backend/LexBoxApi/Services/ProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ public async Task<Guid> CreateProject(CreateProjectInput input, Guid userId)
return projectId;
}

public async Task<string> BackupProject(ResetProjectByAdminInput input)
{
var backupFile = await _hgService.BackupRepo(input.Code);
return backupFile;
}

public async Task<string> ResetProject(ResetProjectByAdminInput input)
{
var backupPath = await _hgService.ResetRepo(input.Code);
return backupPath;
}

public async Task<DateTimeOffset?> UpdateLastCommit(string projectCode)
{
var lastCommitFromHg = await _hgService.GetLastCommitTimeFromHg(projectCode);
Expand Down
2 changes: 2 additions & 0 deletions backend/LexCore/ServiceInterfaces/IHgService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ public interface IHgService
Task<Changeset[]> GetChangesets(string projectCode);
Task DeleteRepo(string code);
Task SoftDeleteRepo(string code, string deletedRepoSuffix);
Task<string> BackupRepo(string code);
Task<string> ResetRepo(string code);
}
8 changes: 8 additions & 0 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,16 @@
"change_role_modal": {
"title": "Choose role for {name}"
},
"reset_project_modal": {
"title": "Reset project {name}",
"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.",
"admin_required": "This action can only be performed by site admins",
myieye marked this conversation as resolved.
Show resolved Hide resolved
"reset_project": "Reset project (NO UNDO)"
},
"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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
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';
Expand All @@ -27,6 +28,7 @@
import { goto } from '$app/navigation';
import MoreSettings from '$lib/components/MoreSettings.svelte';
import { Page } from '$lib/layout';
import AdminContent from '$lib/layout/AdminContent.svelte';

export let data: PageData;
$: user = data.user;
Expand Down Expand Up @@ -56,6 +58,22 @@
}
}

let resetProjectModal: ResetProjectModal;
async function resetProject(): Promise<void> {
const response = await resetProjectModal.open();
if (response === 'submit') {
const url = `/api/project/resetProject/${_project.code}`;
const resetResponse = await fetch(url, {method: 'post'});
rmunn marked this conversation as resolved.
Show resolved Hide resolved
if (resetResponse.ok) {
notifySuccess(
$t('project_page.notifications.reset_project', {
code: _project.code,
})
);
}
}
}

let removeUserModal: DeleteModal;
let userToDelete: ProjectUser | undefined;
async function deleteProjectUser(projectUser: ProjectUser): Promise<void> {
Expand Down Expand Up @@ -242,6 +260,9 @@
{/if}

<ChangeMemberRoleModal projectId={project.id} bind:this={changeMemberRoleModal} />
<AdminContent>
<ResetProjectModal bind:this={resetProjectModal} code={data.code} />
rmunn marked this conversation as resolved.
Show resolved Hide resolved
</AdminContent>

<DeleteModal
bind:this={removeUserModal}
Expand All @@ -256,7 +277,6 @@
</div>

<div class="divider" />

<div class="space-y-2">
<p class="text-2xl mb-4 flex gap-4 items-baseline">
{$t('project_page.history')}
Expand All @@ -276,10 +296,16 @@

<MoreSettings>
<button class="btn btn-error" on:click={softDeleteProject}>
{$t('delete_project_modal.submit')}<TrashIcon />
rmunn marked this conversation as resolved.
Show resolved Hide resolved
</button>
</MoreSettings>
{/if}
<AdminContent>
<p class="text-2xl mb-4">
<button class="btn btn-accent" on:click={() => resetProject()}>
rmunn marked this conversation as resolved.
Show resolved Hide resolved
{$t('project_page.reset_project_modal.title', {name: project.name})}
</button>
</p>
</AdminContent>

<ConfirmDeleteModal bind:this={deleteProjectModal} i18nScope="delete_project_modal" />
{:else}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<script lang="ts">
import Modal, { DialogResponse } from '$lib/components/modals/Modal.svelte';
import Form from '$lib/forms/Form.svelte';
import t from '$lib/i18n';
import Button from '$lib/forms/Button.svelte';

let modal: Modal;
export let code: string;

export async function open(): Promise<DialogResponse> {
console.log('Opening modal', modal);
const x = await modal.openModal();
console.log('Response', x);
rmunn marked this conversation as resolved.
Show resolved Hide resolved
return x;
}

export function submit(): void {
modal.submitModal();
modal.close();
}

export function close(): void {
modal.close();
}

export function resetProject(): void {
alert('Would reset project');
}

let confirmCheck = false;
</script>

<Modal bind:this={modal} on:close={() => close()} bottom>
<Form id="modalForm">
rmunn marked this conversation as resolved.
Show resolved Hide resolved
<p>{$t('project_page.reset_project_modal.title', {code})}</p>
<a rel="external" target="_blank" href="/api/project/backupProject/{code}" download>
<span class="btn">{$t('project_page.reset_project_modal.download_button')}</span>
</a>
<div class="form-control">
<label class="label cursor-pointer">
<input type="checkbox" bind:checked={confirmCheck} class="checkbox" />
rmunn marked this conversation as resolved.
Show resolved Hide resolved
<span class="label-text ml-2">{$t('project_page.reset_project_modal.confirm_downloaded')}</span>
</label>
</div>
</Form>
<svelte:fragment slot="actions" let:submitting>
<Button type="submit" style="btn-primary" on:click={submit} loading={submitting} disabled={!confirmCheck}>
<span>{$t('project_page.reset_project_modal.reset_project')}</span>
</Button>
</svelte:fragment>
</Modal>
Loading