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 all 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
18 changes: 18 additions & 0 deletions backend/LexBoxApi/Controllers/ProjectController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ 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));
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<ActionResult> ResetProject(string code)
{
await _projectService.ResetProject(new Models.Project.ResetProjectByAdminInput(code));
return Ok();
}

[HttpDelete("project/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand Down
3 changes: 2 additions & 1 deletion backend/LexBoxApi/GraphQL/ProjectMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using LexCore.Entities;
using LexCore.Exceptions;
using LexCore.ServiceInterfaces;
using LexCore.Utils;
using LexData;
using LexData.Entities;
using Microsoft.EntityFrameworkCore;
Expand Down Expand Up @@ -153,7 +154,7 @@ public async Task<IQueryable<Project>> 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}";
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);
40 changes: 40 additions & 0 deletions backend/LexBoxApi/Services/HgService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Nodes;
using LexBoxApi.Config;
using LexCore.Config;
using LexCore.Entities;
using LexCore.Exceptions;
using LexCore.ServiceInterfaces;
using LexCore.Utils;
using Microsoft.Extensions.Options;
using Path = System.IO.Path;

Expand Down Expand Up @@ -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<string?> 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}";
Expand Down
11 changes: 11 additions & 0 deletions backend/LexBoxApi/Services/ProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ 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 ResetProject(ResetProjectByAdminInput input)
{
await _hgService.ResetRepo(input.Code);
}

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 ResetRepo(string code);
}
13 changes: 13 additions & 0 deletions backend/LexCore/Utils/FileUtils.cs
Original file line number Diff line number Diff line change
@@ -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(':', '-');
}
}
9 changes: 9 additions & 0 deletions frontend/src/lib/app.postcss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand All @@ -51,6 +56,10 @@ form {
@apply btn-error;
}

.reset-modal .btn[type='submit'] {
@apply btn-accent;
}

.menu {
.text-error {
@apply !text-error;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/components/MoreSettings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<div class="collapse-title text-lg">{$t('more_settings.title')}</div>
<div class="collapse-content">
<div class="divider mt-0" />
<div class="flex justify-end">
<div class="flex justify-end gap-4">
<slot />
</div>
</div>
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/lib/forms/Checkbox.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
<script lang="ts">
import FormFieldError from './FormFieldError.svelte';
import { randomFieldId } from './utils';

export let label: string;
export let value: boolean;
export let id = randomFieldId();
export let error: string | string[] | undefined = undefined;
</script>

<div class="form-control w-full">
<label class="label cursor-pointer">
<label class="label cursor-pointer justify-normal pb-0">
<input {id} type="checkbox" bind:checked={value} class="checkbox mr-4" />
<span class="label-text">{label}</span>
<input {id} type="checkbox" bind:checked={value} class="checkbox" />
</label>
<FormFieldError {error} {id} />
</div>
2 changes: 1 addition & 1 deletion frontend/src/lib/forms/FormField.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
</div>

<style lang="postcss">
:global(.form-control a) {
:global(.form-control .label a) {
@apply link;
}
</style>
11 changes: 11 additions & 0 deletions frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/icons/CircleArrowIcon.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<span class="i-mdi-replay text-2xl" />
2 changes: 2 additions & 0 deletions frontend/src/lib/icons/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,6 +10,7 @@ import TrashIcon from './TrashIcon.svelte';

export {
AuthenticatedUserIcon,
CircleArrowIcon,
HamburgerIcon,
LogoutIcon,
UserAddOutline,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -57,6 +58,11 @@
}
}

let resetProjectModal: ResetProjectModal;
async function resetProject(): Promise<void> {
await resetProjectModal.open(_project.code);
}

let removeUserModal: DeleteModal;
let userToDelete: ProjectUser | undefined;
async function deleteProjectUser(projectUser: ProjectUser): Promise<void> {
Expand Down Expand Up @@ -265,7 +271,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 @@ -287,6 +292,12 @@
<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>
<AdminContent>
<button class="btn btn-accent" on:click={() => resetProject()}>
{$t('project_page.reset_project_modal.title')}<CircleArrowIcon />
</button>
<ResetProjectModal bind:this={resetProjectModal} i18nScope="project_page.reset_project_modal" />
</AdminContent>
</MoreSettings>
{/if}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<script context="module" lang="ts">
export type ResetProjectModalI18nShape = {
title: string,
submit: string,
/* eslint-disable @typescript-eslint/naming-convention */
download_button: string,
confirm_downloaded: string,
confirm_downloaded_error: string,
confirm_project_code: string,
confirm_project_code_error: string,
reset_project_notification: string,
/* eslint-enable @typescript-eslint/naming-convention */
};
</script>

<script lang="ts">
import Input from '$lib/forms/Input.svelte';
import Checkbox from '$lib/forms/Checkbox.svelte';
import { tScoped, type I18nShapeKey } from '$lib/i18n';
import { z } from 'zod';
import { FormModal } from '$lib/components/modals';
import type { FormModalResult } from '$lib/components/modals/FormModal.svelte';
import { CircleArrowIcon } from '$lib/icons';
import { notifySuccess } from '$lib/notify';

export let i18nScope: I18nShapeKey<ResetProjectModalI18nShape>;

let code: string;

export async function open(_code: string): Promise<FormModalResult<Schema>> {
code = _code;
return await resetProjectModal.open(async () => {
const url = `/api/project/resetProject/${code}`;
const resetResponse = await fetch(url, {method: 'post'});
if (resetResponse.ok) {
notifySuccess(
$t('reset_project_notification', { code })
)}
});
}

$: t = tScoped<ResetProjectModalI18nShape>(i18nScope);

$: verify = z.object({
confirmProjectCode: z.string().refine((value) => value === code, {message: $t('confirm_project_code_error')}),
confirmDownloaded: z.boolean().refine((value) => value, {message: $t('confirm_downloaded_error')}),
});

type Schema = typeof verify;

let resetProjectModal: FormModal<Schema>;
$: modalForm = resetProjectModal?.form();
</script>

<div class="reset-modal contents">
<FormModal bind:this={resetProjectModal} schema={verify} let:errors>
<span slot="title">{$t('title')}</span>
<div class="form-control">
<a rel="external" href="/api/project/backupProject/{code}"
class="btn btn-success" download>
{$t('download_button')}
<span class="i-mdi-download text-2xl" />
</a>
</div>
<Checkbox
id="confirmDownloaded"
label={$t('confirm_downloaded')}
bind:value={$modalForm.confirmDownloaded}
error={errors.confirmDownloaded} />
<Input
id="confirmProjectCode"
type="text"
label={$t('confirm_project_code')}
error={errors.confirmProjectCode}
bind:value={$modalForm.confirmProjectCode}
/>
<svelte:fragment slot="submitText">
{$t('submit')}
myieye marked this conversation as resolved.
Show resolved Hide resolved
<CircleArrowIcon />
</svelte:fragment>
</FormModal>
</div>