mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-15 17:56:17 +01:00
Feature/import export encryption (#1533)
Add option to encrypt repository exports with a password and add possibility to decrypt them on repository import. Also make the repository export asynchronous. This implies that the repository export will be created on the server and can be downloaded multiple times. The repository export will be deleted automatically 10 days after creation.
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { FileUpload, LabelWithHelpIcon, Checkbox } from "@scm-manager/ui-components";
|
||||
import { FileUpload, LabelWithHelpIcon, Checkbox, InputField } from "@scm-manager/ui-components";
|
||||
import { File } from "@scm-manager/ui-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -32,34 +32,57 @@ type Props = {
|
||||
setValid: (valid: boolean) => void;
|
||||
compressed: boolean;
|
||||
setCompressed: (compressed: boolean) => void;
|
||||
password: string;
|
||||
setPassword: (password: string) => void;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const ImportFromBundleForm: FC<Props> = ({ setFile, setValid, compressed, setCompressed, disabled }) => {
|
||||
const ImportFromBundleForm: FC<Props> = ({
|
||||
setFile,
|
||||
setValid,
|
||||
compressed,
|
||||
setCompressed,
|
||||
password,
|
||||
setPassword,
|
||||
disabled
|
||||
}) => {
|
||||
const [t] = useTranslation("repos");
|
||||
|
||||
return (
|
||||
<div className="columns">
|
||||
<div className="column is-half is-vcentered">
|
||||
<LabelWithHelpIcon label={t("import.bundle.title")} helpText={t("import.bundle.helpText")} />
|
||||
<FileUpload
|
||||
handleFile={(file: File) => {
|
||||
setFile(file);
|
||||
setValid(!!file);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<div className="columns">
|
||||
<div className="column is-half is-vcentered">
|
||||
<LabelWithHelpIcon label={t("import.bundle.title")} helpText={t("import.bundle.helpText")} />
|
||||
<FileUpload
|
||||
handleFile={(file: File) => {
|
||||
setFile(file);
|
||||
setValid(!!file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="column is-half is-vcentered">
|
||||
<Checkbox
|
||||
checked={compressed}
|
||||
onChange={(value, name) => setCompressed(value)}
|
||||
label={t("import.compressed.label")}
|
||||
disabled={disabled}
|
||||
helpText={t("import.compressed.helpText")}
|
||||
title={t("import.compressed.label")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column is-half is-vcentered">
|
||||
<Checkbox
|
||||
checked={compressed}
|
||||
onChange={(value, name) => setCompressed(value)}
|
||||
label={t("import.compressed.label")}
|
||||
disabled={disabled}
|
||||
helpText={t("import.compressed.helpText")}
|
||||
title={t("import.compressed.label")}
|
||||
/>
|
||||
<div className="columns">
|
||||
<div className="column is-half is-vcentered">
|
||||
<InputField
|
||||
value={password}
|
||||
onChange={value => setPassword(value)}
|
||||
type="password"
|
||||
label={t("import.bundle.password.title")}
|
||||
helpText={t("import.bundle.password.helpText")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import RepositoryInformationForm from "./RepositoryInformationForm";
|
||||
import { apiClient, ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import ImportFromBundleForm from "./ImportFromBundleForm";
|
||||
import ImportFullRepositoryForm from "./ImportFullRepositoryForm";
|
||||
|
||||
type Props = {
|
||||
@@ -44,9 +43,9 @@ const ImportFullRepository: FC<Props> = ({ url, repositoryType, setImportPending
|
||||
type: repositoryType,
|
||||
contact: "",
|
||||
description: "",
|
||||
_links: {},
|
||||
_links: {}
|
||||
});
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [valid, setValid] = useState({ namespaceAndName: false, contact: true, file: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
@@ -59,7 +58,7 @@ const ImportFullRepository: FC<Props> = ({ url, repositoryType, setImportPending
|
||||
setLoading(loading);
|
||||
};
|
||||
|
||||
const isValid = () => Object.values(valid).every((v) => v);
|
||||
const isValid = () => Object.values(valid).every(v => v);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -69,7 +68,7 @@ const ImportFullRepository: FC<Props> = ({ url, repositoryType, setImportPending
|
||||
apiClient
|
||||
.postBinary(url, formData => {
|
||||
formData.append("bundle", file, file?.name);
|
||||
formData.append("repository", JSON.stringify(repo));
|
||||
formData.append("repository", JSON.stringify({ ...repo, password }));
|
||||
})
|
||||
.then(response => {
|
||||
const location = response.headers.get("Location");
|
||||
@@ -91,7 +90,12 @@ const ImportFullRepository: FC<Props> = ({ url, repositoryType, setImportPending
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<ErrorNotification error={error} />
|
||||
<ImportFullRepositoryForm setFile={setFile} setValid={(file: boolean) => setValid({ ...valid, file })}/>
|
||||
<ImportFullRepositoryForm
|
||||
setFile={setFile}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
setValid={(file: boolean) => setValid({ ...valid, file })}
|
||||
/>
|
||||
<hr />
|
||||
<NamespaceAndNameFields
|
||||
repository={repo}
|
||||
|
||||
@@ -23,21 +23,23 @@
|
||||
*/
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { FileUpload, LabelWithHelpIcon, Checkbox } from "@scm-manager/ui-components";
|
||||
import { FileUpload, InputField, LabelWithHelpIcon } from "@scm-manager/ui-components";
|
||||
import { File } from "@scm-manager/ui-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
setFile: (file: File) => void;
|
||||
setValid: (valid: boolean) => void;
|
||||
password: string;
|
||||
setPassword: (password: string) => void;
|
||||
};
|
||||
|
||||
const ImportFullRepositoryForm: FC<Props> = ({ setFile, setValid}) => {
|
||||
const ImportFullRepositoryForm: FC<Props> = ({ setFile, setValid, password, setPassword }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
|
||||
return (
|
||||
<div className="columns">
|
||||
<div className="column is-vcentered">
|
||||
<div className="column is-half is-vcentered">
|
||||
<LabelWithHelpIcon label={t("import.fullImport.title")} helpText={t("import.fullImport.helpText")} />
|
||||
<FileUpload
|
||||
handleFile={(file: File) => {
|
||||
@@ -46,6 +48,15 @@ const ImportFullRepositoryForm: FC<Props> = ({ setFile, setValid}) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="column is-half is-vcentered">
|
||||
<InputField
|
||||
value={password}
|
||||
onChange={value => setPassword(value)}
|
||||
type="password"
|
||||
label={t("import.bundle.password.title")}
|
||||
helpText={t("import.bundle.password.helpText")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,12 +45,12 @@ const ImportRepositoryFromBundle: FC<Props> = ({ url, repositoryType, setImportP
|
||||
description: "",
|
||||
_links: {}
|
||||
});
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [valid, setValid] = useState({ namespaceAndName: false, contact: true, file: false });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [compressed, setCompressed] = useState(false);
|
||||
const [compressed, setCompressed] = useState(true);
|
||||
const history = useHistory();
|
||||
const [t] = useTranslation("repos");
|
||||
|
||||
@@ -69,7 +69,7 @@ const ImportRepositoryFromBundle: FC<Props> = ({ url, repositoryType, setImportP
|
||||
apiClient
|
||||
.postBinary(compressed ? url + "?compressed=true" : url, formData => {
|
||||
formData.append("bundle", file, file?.name);
|
||||
formData.append("repository", JSON.stringify(repo));
|
||||
formData.append("repository", JSON.stringify({ ...repo, password }));
|
||||
})
|
||||
.then(response => {
|
||||
const location = response.headers.get("Location");
|
||||
@@ -96,6 +96,8 @@ const ImportRepositoryFromBundle: FC<Props> = ({ url, repositoryType, setImportP
|
||||
setValid={(file: boolean) => setValid({ ...valid, file })}
|
||||
compressed={compressed}
|
||||
setCompressed={setCompressed}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
disabled={loading}
|
||||
/>
|
||||
<hr />
|
||||
|
||||
@@ -25,6 +25,7 @@ import React, { FC } from "react";
|
||||
import { Link, RepositoryType } from "@scm-manager/ui-types";
|
||||
import { LabelWithHelpIcon, Radio } from "@scm-manager/ui-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
repositoryType: RepositoryType;
|
||||
@@ -33,6 +34,12 @@ type Props = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const RadioGroup = styled.div`
|
||||
label {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const ImportTypeSelect: FC<Props> = ({ repositoryType, importType, setImportType, disabled }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
|
||||
@@ -45,18 +52,20 @@ const ImportTypeSelect: FC<Props> = ({ repositoryType, importType, setImportType
|
||||
return (
|
||||
<>
|
||||
<LabelWithHelpIcon label={t("import.importTypes.label")} key="import.importTypes.label" />
|
||||
{(repositoryType._links.import as Link[]).map((type, index) => (
|
||||
<Radio
|
||||
name={type.name}
|
||||
checked={importType === type.name}
|
||||
value={type.name}
|
||||
label={t(`import.importTypes.${type.name}.label`)}
|
||||
helpText={t(`import.importTypes.${type.name}.helpText`)}
|
||||
onChange={changeImportType}
|
||||
key={index}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
<RadioGroup>
|
||||
{(repositoryType._links.import as Link[]).map((type, index) => (
|
||||
<Radio
|
||||
name={type.name}
|
||||
checked={importType === type.name}
|
||||
value={type.name}
|
||||
label={t(`import.importTypes.${type.name}.label`)}
|
||||
helpText={t(`import.importTypes.${type.name}.helpText`)}
|
||||
onChange={changeImportType}
|
||||
key={index}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,10 +25,9 @@ import React, { FC } from "react";
|
||||
import { Redirect, useRouteMatch } from "react-router-dom";
|
||||
import RepositoryForm from "../components/form";
|
||||
import { Repository } from "@scm-manager/ui-types";
|
||||
import { ErrorNotification, Subtitle } from "@scm-manager/ui-components";
|
||||
import { ErrorNotification, Subtitle, urls } from "@scm-manager/ui-components";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import RepositoryDangerZone from "./RepositoryDangerZone";
|
||||
import { urls } from "@scm-manager/ui-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ExportRepository from "./ExportRepository";
|
||||
import { useIndexLinks, useUpdateRepository } from "@scm-manager/ui-api";
|
||||
@@ -58,7 +57,7 @@ const EditRepo: FC<Props> = ({ repository }) => {
|
||||
<Subtitle subtitle={t("repositoryForm.subtitle")} />
|
||||
<ErrorNotification error={error} />
|
||||
<RepositoryForm repository={repository} loading={isLoading} modifyRepository={update} />
|
||||
<ExportRepository repository={repository} />
|
||||
{repository._links.exportInfo && <ExportRepository repository={repository} />}
|
||||
<ExtensionPoint name="repo-config.route" props={extensionProps} renderAll={true} />
|
||||
<RepositoryDangerZone repository={repository} indexLinks={indexLinks} />
|
||||
</>
|
||||
|
||||
@@ -21,70 +21,170 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { FC, useState } from "react";
|
||||
import { Button, Checkbox, Level, Notification, Subtitle } from "@scm-manager/ui-components";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
DateShort,
|
||||
ErrorNotification,
|
||||
InputField,
|
||||
Level,
|
||||
Notification,
|
||||
Subtitle
|
||||
} from "@scm-manager/ui-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, Repository } from "@scm-manager/ui-types";
|
||||
import { ExportInfo, Link, Repository } from "@scm-manager/ui-types";
|
||||
import { useExportInfo, useExportRepository } from "@scm-manager/ui-api";
|
||||
import styled from "styled-components";
|
||||
|
||||
const InfoBox = styled.div`
|
||||
white-space: pre-line;
|
||||
background-color: #ccecf9;
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
border-radius: 2px;
|
||||
border-left: 0.2rem solid;
|
||||
border-color: #33b2e8;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
};
|
||||
|
||||
const ExportInterruptedNotification = () => {
|
||||
const [t] = useTranslation("repos");
|
||||
return <Notification type="warning">{t("export.exportInfo.interrupted")}</Notification>;
|
||||
};
|
||||
|
||||
const ExportInfoBox: FC<{ exportInfo: ExportInfo }> = ({ exportInfo }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
return (
|
||||
<InfoBox>
|
||||
<strong>{t("export.exportInfo.infoBoxTitle")}</strong>
|
||||
<p>{t("export.exportInfo.exporter", { username: exportInfo.exporterName })}</p>
|
||||
<p>
|
||||
{t("export.exportInfo.created")}
|
||||
<DateShort date={exportInfo.created} />
|
||||
</p>
|
||||
<br />
|
||||
<p>{exportInfo.withMetadata ? t("export.exportInfo.repositoryArchive") : t("export.exportInfo.repository")}</p>
|
||||
{exportInfo.encrypted && (
|
||||
<>
|
||||
<br />
|
||||
<p>{t("export.exportInfo.encrypted")}</p>
|
||||
</>
|
||||
)}
|
||||
</InfoBox>
|
||||
);
|
||||
};
|
||||
|
||||
const ExportRepository: FC<Props> = ({ repository }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
const [compressed, setCompressed] = useState(true);
|
||||
const [fullExport, setFullExport] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [encrypt, setEncrypt] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const { isLoading: isLoadingInfo, error: errorInfo, data: exportInfo } = useExportInfo(repository);
|
||||
const {
|
||||
isLoading: isLoadingExport,
|
||||
error: errorExport,
|
||||
data: exportedInfo,
|
||||
exportRepository
|
||||
} = useExportRepository();
|
||||
|
||||
const createExportLink = () => {
|
||||
if (fullExport) {
|
||||
return (repository?._links?.fullExport as Link).href;
|
||||
} else {
|
||||
let exportLink = (repository?._links.export as Link).href;
|
||||
if (compressed) {
|
||||
exportLink += "?compressed=true";
|
||||
}
|
||||
return exportLink;
|
||||
useEffect(() => {
|
||||
if (exportedInfo && exportedInfo?._links.download) {
|
||||
window.location.href = (exportedInfo?._links.download as Link).href;
|
||||
}
|
||||
};
|
||||
}, [exportedInfo]);
|
||||
|
||||
if (!repository?._links?.export) {
|
||||
if (!repository._links.export) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderExportInfo = () => {
|
||||
if (!exportInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (exportInfo.status === "INTERRUPTED") {
|
||||
return <ExportInterruptedNotification />;
|
||||
} else {
|
||||
return <ExportInfoBox exportInfo={exportInfo} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<Subtitle subtitle={t("export.subtitle")} />
|
||||
<Notification type="inherit">
|
||||
{t("export.notification")}
|
||||
</Notification>
|
||||
<>
|
||||
<ErrorNotification error={errorInfo} />
|
||||
<ErrorNotification error={errorExport} />
|
||||
<Notification type="inherit">{t("export.notification")}</Notification>
|
||||
<Checkbox
|
||||
checked={fullExport || compressed}
|
||||
label={t("export.compressed.label")}
|
||||
onChange={setCompressed}
|
||||
helpText={t("export.compressed.helpText")}
|
||||
disabled={fullExport}
|
||||
/>
|
||||
{repository?._links?.fullExport && (
|
||||
<Checkbox
|
||||
checked={fullExport || compressed}
|
||||
label={t("export.compressed.label")}
|
||||
onChange={setCompressed}
|
||||
helpText={t("export.compressed.helpText")}
|
||||
disabled={fullExport}
|
||||
checked={fullExport}
|
||||
label={t("export.fullExport.label")}
|
||||
onChange={setFullExport}
|
||||
helpText={t("export.fullExport.helpText")}
|
||||
/>
|
||||
{repository?._links?.fullExport && (
|
||||
<Checkbox
|
||||
checked={fullExport}
|
||||
label={t("export.fullExport.label")}
|
||||
onChange={setFullExport}
|
||||
helpText={t("export.fullExport.helpText")}
|
||||
)}
|
||||
<Checkbox
|
||||
checked={encrypt}
|
||||
label={t("export.encrypt.label")}
|
||||
onChange={setEncrypt}
|
||||
helpText={t("export.encrypt.helpText")}
|
||||
/>
|
||||
{encrypt && (
|
||||
<div className="columns column is-half">
|
||||
<InputField
|
||||
label={t("export.password.label")}
|
||||
helpText={t("export.password.helpText")}
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
<Level
|
||||
right={
|
||||
<a color="primary" href={createExportLink()} onClick={() => setLoading(true)}>
|
||||
<Button color="primary" label={t("export.exportButton")} icon="file-export" />
|
||||
</div>
|
||||
)}
|
||||
{renderExportInfo()}
|
||||
<Level
|
||||
right={
|
||||
<ButtonGroup>
|
||||
<a color="info" href={(exportInfo?._links.download as Link)?.href}>
|
||||
<Button
|
||||
color="info"
|
||||
disabled={isLoadingInfo || isLoadingExport || !exportInfo?._links.download}
|
||||
label={t("export.downloadExportButton")}
|
||||
icon="download"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
{loading && <Notification onClose={() => setLoading(false)}>{t("export.exportStarted")}</Notification>}
|
||||
</>
|
||||
<Button
|
||||
color="primary"
|
||||
action={() =>
|
||||
exportRepository(repository, {
|
||||
compressed,
|
||||
password: encrypt ? password : "",
|
||||
withMetadata: fullExport
|
||||
})
|
||||
}
|
||||
loading={isLoadingInfo || isLoadingExport}
|
||||
disabled={isLoadingInfo || isLoadingExport}
|
||||
label={t("export.createExportButton")}
|
||||
icon="file-export"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportRepository;
|
||||
|
||||
@@ -37,12 +37,6 @@ import ImportFullRepository from "../components/ImportFullRepository";
|
||||
import { useRepositoryTypes } from "@scm-manager/ui-api";
|
||||
import { Prompt } from "react-router-dom";
|
||||
|
||||
type Props = {
|
||||
repositoryTypes: RepositoryType[];
|
||||
pageLoading: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
const ImportPendingLoading = ({ importPending }: { importPending: boolean }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
if (!importPending) {
|
||||
|
||||
Reference in New Issue
Block a user