diff --git a/docs/de/user/repo/assets/repository-settings-general-export.png b/docs/de/user/repo/assets/repository-settings-general-export.png new file mode 100644 index 0000000000..8007dabb89 Binary files /dev/null and b/docs/de/user/repo/assets/repository-settings-general-export.png differ diff --git a/docs/de/user/repo/assets/repository-settings-general-git.png b/docs/de/user/repo/assets/repository-settings-general-git.png deleted file mode 100644 index abc9def407..0000000000 Binary files a/docs/de/user/repo/assets/repository-settings-general-git.png and /dev/null differ diff --git a/docs/de/user/repo/index.md b/docs/de/user/repo/index.md index d047509152..c9874b9967 100644 --- a/docs/de/user/repo/index.md +++ b/docs/de/user/repo/index.md @@ -48,6 +48,8 @@ Zusätzlich zum normalen Repository Import gibt es die Möglichkeit ein Reposito Dieses Repository Archiv muss von einem anderen SCM-Manager exportiert worden sein und wird vor dem Import auf Kompatibilität der Daten überprüft (der SCM-Manager und alle installierten Plugins müssen mindestens die Version des exportierenden Systems haben). +Ist die zu importierende Datei verschlüsselt, muss das korrekte Passwort zum Entschlüsseln mitgeliefert werden. +Wird kein Passwort gesetzt, geht der SCM-Manager davon aus, dass die Datei unverschlüsselt ist. ![Repository importieren](assets/import-repository.png) diff --git a/docs/de/user/repo/settings.md b/docs/de/user/repo/settings.md index 6af7344034..39c2b90569 100644 --- a/docs/de/user/repo/settings.md +++ b/docs/de/user/repo/settings.md @@ -18,7 +18,12 @@ Strategie `benutzerdefiniert` ausgewählt ist, kann zusätzlich zum Repository N Ein archiviertes Repository kann nicht mehr verändert werden. In dem Bereich "Repository exportieren" kann das Repository in unterschiedlichen Formaten exportiert werden. -Während des laufenden Exports kann auf das Repository nur lesend zugriffen werden. +Während eines laufenden Exports, kann auf das Repository nur lesend zugriffen werden. +Der Repository Export wird asynchron erstellt und auf dem Server gespeichert. +Existiert bereits ein Export für dieses Repository auf dem Server, wird dieser vorher gelöscht, da es immer nur einen Export pro Repository geben kann. +Exporte werden 10 Tage nach deren Erstellung automatisch vom SCM-Server gelöscht. +Falls ein Export existiert, wird über die blaue Info-Box angezeigt von wem, wann und wie dieser Export erzeugt wurde. + Das Ausgabeformat des Repository kann über die angebotenen Optionen verändert werden: * `Standard`: Werden keine Optionen ausgewählt, wird das Repository im Standard Format exportiert. Git und Mercurial werden dabei als `Tar Archiv` exportiert und Subversion nutzt das `Dump` Format. @@ -27,8 +32,9 @@ Das Ausgabeformat des Repository kann über die angebotenen Optionen verändert weitere Metadaten enthält. Für diesen Export sollte sichergestellt werden, dass alle installierten Plugins aktuell sind. Ein Import eines so exportierten Repositories ist nur in einem SCM-Manager mit derselben oder einer neueren Version möglich. Dieses gilt ebenso für alle installierten Plugins. +* `Verschlüsseln`: Die Export-Datei wird mit dem gesetzten Passwort verschlüsselt. Zum Entschlüsseln muss das exakt gleiche Passwort verwendet werden. -![Repository-Settings-General-Git](assets/repository-settings-general-git.png) +![Repository-Settings-General-Export](assets/repository-settings-general-export.png) ### Berechtigungen diff --git a/docs/en/user/repo/assets/repository-settings-general-export.png b/docs/en/user/repo/assets/repository-settings-general-export.png new file mode 100644 index 0000000000..164e72d904 Binary files /dev/null and b/docs/en/user/repo/assets/repository-settings-general-export.png differ diff --git a/docs/en/user/repo/assets/repository-settings-general-git.png b/docs/en/user/repo/assets/repository-settings-general-git.png deleted file mode 100644 index 018b31f8af..0000000000 Binary files a/docs/en/user/repo/assets/repository-settings-general-git.png and /dev/null differ diff --git a/docs/en/user/repo/index.md b/docs/en/user/repo/index.md index 347e47fc17..b3bd4153b8 100644 --- a/docs/en/user/repo/index.md +++ b/docs/en/user/repo/index.md @@ -46,6 +46,8 @@ In addition to the normal repository import, there is the possibility to import This repository archive must have been exported from another SCM-Manager and is checked for data compatibility before import (the SCM-Manager and all its installed plugins have to have at least the versions of the system the export has been created on). +If the file to be imported is encrypted, the correct password must be supplied for decryption. +If no password is set, the SCM Manager assumes that the file is unencrypted. ![Import Repository](assets/import-repository.png) diff --git a/docs/en/user/repo/settings.md b/docs/en/user/repo/settings.md index 10c6b874a8..8a7bdd9162 100644 --- a/docs/en/user/repo/settings.md +++ b/docs/en/user/repo/settings.md @@ -17,6 +17,11 @@ repository is marked as archived, it can no longer be modified. In the "Export repository" section the repository can be exported in different formats. During the export the repository cannot be modified! +When creating the export, the export file is saved on the server and can thus be downloaded repeatedly. +If an export already exists on the server, it will be deleted beforehand when a new export is created, as there can only ever be one export per repository. +Exports are automatically deleted from the SCM-Server 10 days after they are created. +If an export exists, the blue info box shows by whom, when and how this export was created. + The output format of the repository can be changed via the offered options: * `Standard`: If no options are selected, the repository will be exported in the standard format. Git and Mercurial are exported as `Tar archive` and Subversion uses the `Dump` format. @@ -25,8 +30,9 @@ The output format of the repository can be changed via the offered options: besides the repository. When you use this, please make sure all installed plugins are up to date. An import of such an export is possible only in an SCM-Manager with the same or a newer version. The same is valid for all installed plugins. +* `Encrypt`: The export file will be encrypted using the provided password. The same password must be used to decrypt this export file. -![Repository-Settings-General-Git](assets/repository-settings-general-git.png) +![Repository-Settings-General-Export](assets/repository-settings-general-export.png) ### Permissions diff --git a/gradle/changelog/import_export_encryption.yaml b/gradle/changelog/import_export_encryption.yaml new file mode 100644 index 0000000000..89da78515a --- /dev/null +++ b/gradle/changelog/import_export_encryption.yaml @@ -0,0 +1,4 @@ +- type: added + description: Add option to encrypt repository exports with a password and decrypt them on repository import ([#1533](https://github.com/scm-manager/scm-manager/pull/1533)) +- type: added + description: Make repository export asynchronous. ([#1533](https://github.com/scm-manager/scm-manager/pull/1533)) diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index dc5dd4702b..710a23f265 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -89,6 +89,9 @@ public class VndMediaType { public static final String API_KEY = PREFIX + "apiKey" + SUFFIX; public static final String API_KEY_COLLECTION = PREFIX + "apiKeyCollection" + SUFFIX; + public static final String REPOSITORY_EXPORT = PREFIX + "repositoryExport" + SUFFIX; + public static final String REPOSITORY_EXPORT_INFO = PREFIX + "repositoryExportInfo" + SUFFIX; + private VndMediaType() { } diff --git a/scm-core/src/test/java/sonia/scm/repository/DefaultRepositoryExportingCheckTest.java b/scm-core/src/test/java/sonia/scm/repository/DefaultRepositoryExportingCheckTest.java index fc3ea0c428..ef29ca41fe 100644 --- a/scm-core/src/test/java/sonia/scm/repository/DefaultRepositoryExportingCheckTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/DefaultRepositoryExportingCheckTest.java @@ -24,8 +24,6 @@ package sonia.scm.repository; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/ExportableBlobFileStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/ExportableBlobFileStore.java index c76515fcfe..c1da679970 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/ExportableBlobFileStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/ExportableBlobFileStore.java @@ -33,6 +33,8 @@ import static java.util.Optional.of; class ExportableBlobFileStore extends ExportableDirectoryBasedFileStore { + private static final String EXCLUDED_EXPORT_STORE = "repository-export"; + static final Function>> BLOB_FACTORY = storeType -> storeType == StoreType.BLOB ? of(ExportableBlobFileStore::new) : empty(); @@ -46,6 +48,9 @@ class ExportableBlobFileStore extends ExportableDirectoryBasedFileStore { } boolean shouldIncludeFile(Path file) { + if (getDirectory().toString().endsWith(EXCLUDED_EXPORT_STORE)) { + return false; + } return file.getFileName().toString().endsWith(".blob"); } } diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/ExportableDirectoryBasedFileStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/ExportableDirectoryBasedFileStore.java index 47dfd32830..8023d59ad3 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/ExportableDirectoryBasedFileStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/ExportableDirectoryBasedFileStore.java @@ -73,4 +73,8 @@ abstract class ExportableDirectoryBasedFileStore implements ExportableStore { putFileContentIntoStream(exporter, fileOrDir); } } + + protected Path getDirectory() { + return directory; + } } diff --git a/scm-dao-xml/src/test/java/sonia/scm/store/ExportableBlobFileStoreTest.java b/scm-dao-xml/src/test/java/sonia/scm/store/ExportableBlobFileStoreTest.java new file mode 100644 index 0000000000..bb0e2f78fe --- /dev/null +++ b/scm-dao-xml/src/test/java/sonia/scm/store/ExportableBlobFileStoreTest.java @@ -0,0 +1,70 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.store; + + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExportableBlobFileStoreTest { + + @Test + void shouldIgnoreStoreIfExcludedStore() { + Path dir = Paths.get("test/path/repository-export"); + ExportableBlobFileStore exportableBlobFileStore = new ExportableBlobFileStore(dir); + + Path file = Paths.get(dir.toString(), "some.blob"); + boolean result = exportableBlobFileStore.shouldIncludeFile(file); + + assertThat(result).isFalse(); + } + + @Test + void shouldIgnoreStoreIfNotBlob() { + Path dir = Paths.get("test/path/any-store"); + ExportableBlobFileStore exportableBlobFileStore = new ExportableBlobFileStore(dir); + + Path file = Paths.get(dir.toString(), "some.unblob"); + boolean result = exportableBlobFileStore.shouldIncludeFile(file); + + assertThat(result).isFalse(); + } + + @Test + void shouldIncludeStore() { + Path dir = Paths.get("test/path/any-blob-store"); + ExportableBlobFileStore exportableBlobFileStore = new ExportableBlobFileStore(dir); + + Path file = Paths.get(dir.toString(), "some.blob"); + boolean result = exportableBlobFileStore.shouldIncludeFile(file); + + assertThat(result).isTrue(); + } + +} diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryBlobStore.java b/scm-test/src/main/java/sonia/scm/store/InMemoryBlobStore.java new file mode 100644 index 0000000000..e95742b6ac --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryBlobStore.java @@ -0,0 +1,129 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.store; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class InMemoryBlobStore implements BlobStore { + + private final List blobs = new ArrayList<>(); + + @Override + public Blob create() { + InMemoryBlob blob = new InMemoryBlob(UUID.randomUUID().toString()); + blobs.add(blob); + return blob; + } + + @Override + public Blob create(String id) { + InMemoryBlob blob = new InMemoryBlob(id); + blobs.add(blob); + return blob; + } + + @Override + public void remove(Blob blob) { + blobs.remove(blob); + } + + @Override + public List getAll() { + return blobs; + } + + @Override + public void clear() { + blobs.clear(); + } + + @Override + public void remove(String id) { + blobs.stream() + .filter(b -> b.getId().equals(id)) + .findFirst() + .ifPresent(blobs::remove); + } + + @Override + public Blob get(String id) { + return blobs.stream() + .filter(b -> b.getId().equals(id)) + .findFirst() + .orElse(null); + } + + private static class InMemoryBlob implements Blob { + + private final String id; + private byte[] bytes = new byte[0]; + + private InMemoryBlob(String id) { + this.id = id; + } + + @Override + public void commit() { + //Do nothing + } + + @Override + public String getId() { + return id; + } + + @Override + public InputStream getInputStream() throws FileNotFoundException { + return new ByteArrayInputStream(bytes); + } + + @Override + public OutputStream getOutputStream() throws IOException { + return new InMemoryBlobByteArrayOutputStream(); + } + + @Override + public long getSize() { + return bytes.length; + } + + private class InMemoryBlobByteArrayOutputStream extends ByteArrayOutputStream { + + @Override + public void close() throws IOException { + bytes = super.toByteArray(); + super.close(); + } + } + } +} diff --git a/scm-test/src/test/java/store/InMemoryBlobStoreTest.java b/scm-test/src/test/java/store/InMemoryBlobStoreTest.java new file mode 100644 index 0000000000..c235a46c4a --- /dev/null +++ b/scm-test/src/test/java/store/InMemoryBlobStoreTest.java @@ -0,0 +1,107 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package store; + +import org.junit.jupiter.api.Test; +import sonia.scm.store.Blob; +import sonia.scm.store.InMemoryBlobStore; + +import java.io.IOException; +import java.io.OutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +class InMemoryBlobStoreTest { + + @Test + void shouldStoreToBlob() throws IOException { + String content = "SCM-Manager"; + InMemoryBlobStore inMemoryBlobStore = new InMemoryBlobStore(); + Blob blob = inMemoryBlobStore.create(); + OutputStream os = blob.getOutputStream(); + os.write(content.getBytes()); + os.flush(); + os.close(); + + byte[] result = new byte[11]; + blob.getInputStream().read(result); + assertThat(new String(result)).isEqualTo(content); + } + + @Test + void shouldGetBlobById() { + InMemoryBlobStore inMemoryBlobStore = new InMemoryBlobStore(); + Blob first = inMemoryBlobStore.create("1"); + inMemoryBlobStore.create("2"); + inMemoryBlobStore.create("3"); + + assertThat(inMemoryBlobStore.get("1")).isEqualTo(first); + } + + @Test + void shouldGetAllBlobs() { + InMemoryBlobStore inMemoryBlobStore = new InMemoryBlobStore(); + Blob first = inMemoryBlobStore.create("1"); + Blob second = inMemoryBlobStore.create("2"); + Blob third = inMemoryBlobStore.create("3"); + + assertThat(inMemoryBlobStore.getAll()).contains(first, second, third); + } + + @Test + void shouldRemoveBlobById() { + InMemoryBlobStore inMemoryBlobStore = new InMemoryBlobStore(); + Blob blob = inMemoryBlobStore.create("1"); + + assertThat(inMemoryBlobStore.get("1")).isEqualTo(blob); + + inMemoryBlobStore.remove("1"); + assertThat(inMemoryBlobStore.getAll()).isEmpty(); + } + + @Test + void shouldRemoveBlob() { + InMemoryBlobStore inMemoryBlobStore = new InMemoryBlobStore(); + Blob blob = inMemoryBlobStore.create("1"); + + assertThat(inMemoryBlobStore.get("1")).isEqualTo(blob); + + inMemoryBlobStore.remove(blob); + assertThat(inMemoryBlobStore.getAll()).isEmpty(); + } + + @Test + void shouldClearBlobs() { + InMemoryBlobStore inMemoryBlobStore = new InMemoryBlobStore(); + inMemoryBlobStore.create(); + inMemoryBlobStore.create(); + + assertThat(inMemoryBlobStore.getAll()).hasSize(2); + + inMemoryBlobStore.clear(); + + assertThat(inMemoryBlobStore.getAll()).isEmpty(); + } +} diff --git a/scm-ui/ui-api/src/repositories.ts b/scm-ui/ui-api/src/repositories.ts index e5160cc481..f3bf19086b 100644 --- a/scm-ui/ui-api/src/repositories.ts +++ b/scm-ui/ui-api/src/repositories.ts @@ -23,12 +23,13 @@ */ import { + ExportInfo, Link, Namespace, Repository, RepositoryCollection, RepositoryCreation, - RepositoryTypeCollection, + RepositoryTypeCollection } from "@scm-manager/ui-types"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { apiClient } from "./apiclient"; @@ -37,6 +38,8 @@ import { createQueryString } from "./utils"; import { requiredLink } from "./links"; import { repoQueryKey } from "./keys"; import { concat } from "./urls"; +import { useEffect, useState } from "react"; +import { NotFoundError } from "./errors"; export type UseRepositoriesRequest = { namespace?: Namespace; @@ -227,3 +230,89 @@ export const useUnarchiveRepository = () => { isUnarchived: !!data }; }; + +export const useExportInfo = (repository: Repository): ApiResult => { + const link = requiredLink(repository, "exportInfo"); + //TODO Refetch while exporting to update the page + const { isLoading, error, data } = useQuery( + ["repository", repository.namespace, repository.name, "exportInfo"], + () => apiClient.get(link).then(response => response.json()), + {} + ); + + return { + isLoading, + error: error instanceof NotFoundError ? null : error, + data + }; +}; + +type ExportOptions = { + compressed: boolean; + withMetadata: boolean; + password?: string; +}; + +type ExportRepositoryMutateOptions = { + repository: Repository; + options: ExportOptions; +}; + +const EXPORT_MEDIA_TYPE = "application/vnd.scmm-repositoryExport+json;v=2"; + +export const useExportRepository = () => { + const queryClient = useQueryClient(); + const [intervalId, setIntervalId] = useState(); + useEffect(() => { + return () => { + if (intervalId) { + clearInterval(intervalId); + } + }; + }, [intervalId]); + const { mutate, isLoading, error, data } = useMutation( + ({ repository, options }) => { + const infolink = requiredLink(repository, "exportInfo"); + let link = requiredLink(repository, options.withMetadata ? "fullExport" : "export"); + if (options.compressed) { + link += "?compressed=true"; + } + return apiClient + .post(link, { password: options.password, async: true }, EXPORT_MEDIA_TYPE) + .then(() => queryClient.invalidateQueries(repoQueryKey(repository))) + .then(() => queryClient.invalidateQueries(["repositories"])) + .then(() => { + return new Promise((resolve, reject) => { + const id = setInterval(() => { + apiClient + .get(infolink) + .then(r => r.json()) + .then((info: ExportInfo) => { + if (info._links.download) { + clearInterval(id); + resolve(info); + } + }) + .catch(e => { + clearInterval(id); + reject(e); + }); + }, 1000); + setIntervalId(id); + }); + }); + }, + { + onSuccess: async (_, { repository }) => { + await queryClient.invalidateQueries(repoQueryKey(repository)); + await queryClient.invalidateQueries(["repositories"]); + } + } + ); + return { + exportRepository: (repository: Repository, options: ExportOptions) => mutate({ repository, options }), + isLoading, + error, + data + }; +}; diff --git a/scm-ui/ui-types/src/Repositories.ts b/scm-ui/ui-types/src/Repositories.ts index 5cd65e3061..93cd18ed18 100644 --- a/scm-ui/ui-types/src/Repositories.ts +++ b/scm-ui/ui-types/src/Repositories.ts @@ -33,7 +33,7 @@ export type RepositoryBase = NamespaceAndName & { type: string; contact?: string; description?: string; -} +}; export type Repository = HalRepresentation & RepositoryBase & { @@ -53,6 +53,15 @@ export type RepositoryUrlImport = Repository & { password?: string; }; +export type ExportInfo = HalRepresentation & { + exporterName: string; + created: Date; + withMetadata: boolean; + compressed: boolean; + encrypted: boolean; + status: "FINISHED" | "INTERRUPTED" | "EXPORTING"; +}; + export type Namespace = { namespace: string; _links: Links; diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index e75dcafa10..012cc6a75e 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -74,11 +74,19 @@ }, "bundle": { "title": "Wählen Sie Ihre Datei aus", - "helpText": "Wählen Sie die Datei aus der das Repository importiert werden soll." + "helpText": "Wählen Sie die Datei aus der das Repository importiert werden soll.", + "password": { + "title": "Passwort", + "helpText": "Wenn der importierte Dump verschlüsselt ist, muss das korrekte Passwort mitgeliefert werden." + } }, "fullImport": { "title": "SCM-Manager Repository Archiv", - "helpText": "Wählen Sie das Repository Archiv aus. Das Archiv muss von einer SCM-Manager Instanz exportiert worden sein. Diese Instanz und alle installierten Plugins müssen dieselbe oder eine neuere Version haben." + "helpText": "Wählen Sie das Repository Archiv aus. Das Archiv muss von einer SCM-Manager Instanz exportiert worden sein.", + "password": { + "title": "Passwort", + "helpText": "Wenn das importierte Repository Archiv verschlüsselt ist, muss das korrekte Passwort mitgeliefert werden." + } }, "pending": { "subtitle": "Repository wird importiert...", @@ -254,7 +262,7 @@ }, "export": { "subtitle": "Repository exportieren", - "notification": "Achtung: Während eines laufenden Exports kann auf das Repository nur lesend zugegriffen werden.", + "notification": "Achtung: Während eines laufenden Exports kann auf das Repository nur lesend zugegriffen werden. Erzeugte Repository Exporte werden nach 10 Tagen automatisch gelöscht.", "compressed": { "label": "Komprimieren", "helpText": "Export Datei vor dem Download komprimieren. Reduziert die Downloadgröße." @@ -263,8 +271,25 @@ "label": "Mit Metadaten (Experimentell)", "helpText": "Zusätzlich zum Repository Dump werden Metadaten zum Repository und zur SCM-Instanz exportiert. Installierte Plugins sollten nach Möglichkeit in der neuesten Version installiert sein. Gespeicherte Passwörter funktionieren nicht bei einem Import in andere SCM-Manager Instanzen. Dieses Feature ist noch experimentell. Es sollte (noch) nicht für Backups genutzt werden!" }, - "exportButton": "Repository exportieren", - "exportStarted": "Der Repository Export wurde gestartet. Abhängig von der Größe des Repository kann dies einige Momente dauern." + "encrypt": { + "label": "Verschlüsseln", + "helpText": "Die Export Datei wird mit dem gesetzen Passwort verschlüsselt." + }, + "password": { + "label": "Passwort", + "helpText": "Wird ein Passwort angegeben, wird die Export Datei damit verschlüsselt." + }, + "createExportButton": "Neuen Export erstellen", + "downloadExportButton": "Export herunterladen", + "exportInfo": { + "infoBoxTitle": "Informationen zum gespeicherten Repository Export", + "exporter": "Erstellt von: {{username}}", + "created": "Erstellt am: ", + "repository": "Der Export enthält: \n- das Repository", + "repositoryArchive": "Der Export enthält: \n- das Repository\n- die Metadaten zum Repository, z. B. aus den Plugins\n- eine Umgebungsbeschreibung des exportierenden SCM-Managers\n- weitere Informationen zum Repository", + "encrypted": "Verschlüsselt: Der gespeicherte Export wurde verschlüsselt.", + "interrupted": "Unterbrochen: Der Export wurde während der Erstellung abgebrochen. Womöglich durch einen Server-Neustart." + } }, "sources": { "fileTree": { diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 1ea16a5f0f..8a014ef2bf 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -74,11 +74,19 @@ }, "bundle": { "title": "Dump File", - "helpText": "Select your dump file from which the repository should be imported." + "helpText": "Select your dump file from which the repository should be imported.", + "password": { + "title": "Password", + "helpText": "If the imported dump have to be decrypted the correct password must be provided." + } }, "fullImport": { "title": "SCM-Manager Repository Archive", - "helpText": "Select the archive file which should be imported. The archive must have been exported from an SCM-Manager instance. This instance and all installed plugins have to be of the same or a newer version." + "helpText": "Select the archive file which should be imported. The archive must have been exported from an SCM-Manager instance.", + "password": { + "title": "Password", + "helpText": "If the imported repository archive have to be decrypted the correct password must be provided." + } }, "pending": { "subtitle": "Importing Repository...", @@ -254,7 +262,7 @@ }, "export": { "subtitle": "Repository Export", - "notification": "Attention: During the export the repository cannot be modified.", + "notification": "Attention: During the export the repository cannot be modified. Generated repository exports are automatically deleted after 10 days.", "compressed": { "label": "Compress", "helpText": "Compress the export dump size to reduce the download size." @@ -263,8 +271,25 @@ "label": "With metadata (Experimental)", "helpText": "In addition to the repository dump, metadata about the repository and SCM instance is exported. If possible, ensure that installed plugins are up to date. However, stored passwords will not work if this is imported in other instances of SCM-Manager. This feature is still experimental. Do not use this as a backup mechanism (yet)!" }, - "exportButton": "Export Repository", - "exportStarted": "The repository export was started. Depending on the repository size this may take a while." + "encrypt": { + "label": "Encrypt", + "helpText": "The export file will be encrypted using the provided password." + }, + "password": { + "label": "Password", + "helpText": "If a password is set, it will be used the encrypt the export file." + }, + "createExportButton": "Create new Export", + "downloadExportButton": "Download Export", + "exportInfo": { + "infoBoxTitle": "Stored Repository Export Information", + "exporter": "Created by: {{username}}", + "created": "Created on: ", + "repository": "This export contains: \n- the repository", + "repositoryArchive": "This export contains: \n- the repository\n- the metadata which exist for this repository, e.g. from plugins\n- an environment description for the exporting SCM-Manager\n- additional information for this repository", + "encrypted": "Encrypted: The stored export has been encrypted.", + "interrupted": "Interrupted: The export was aborted during creation. Possibly due to a server restart." + } }, "sources": { "fileTree": { diff --git a/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx b/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx index ba5da91934..81175f7d2d 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx @@ -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 = ({ setFile, setValid, compressed, setCompressed, disabled }) => { +const ImportFromBundleForm: FC = ({ + setFile, + setValid, + compressed, + setCompressed, + password, + setPassword, + disabled +}) => { const [t] = useTranslation("repos"); return ( -
-
- - { - setFile(file); - setValid(!!file); - }} - /> + <> +
+
+ + { + setFile(file); + setValid(!!file); + }} + /> +
+
+ setCompressed(value)} + label={t("import.compressed.label")} + disabled={disabled} + helpText={t("import.compressed.helpText")} + title={t("import.compressed.label")} + /> +
-
- setCompressed(value)} - label={t("import.compressed.label")} - disabled={disabled} - helpText={t("import.compressed.helpText")} - title={t("import.compressed.label")} - /> +
+
+ setPassword(value)} + type="password" + label={t("import.bundle.password.title")} + helpText={t("import.bundle.password.helpText")} + /> +
-
+ ); }; diff --git a/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx b/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx index 1ebcd7bdce..fe55407fef 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportFullRepository.tsx @@ -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 = ({ 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(); @@ -59,7 +58,7 @@ const ImportFullRepository: FC = ({ 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) => { event.preventDefault(); @@ -69,7 +68,7 @@ const ImportFullRepository: FC = ({ 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 = ({ url, repositoryType, setImportPending return (
- setValid({ ...valid, file })}/> + setValid({ ...valid, file })} + />
void; setValid: (valid: boolean) => void; + password: string; + setPassword: (password: string) => void; }; -const ImportFullRepositoryForm: FC = ({ setFile, setValid}) => { +const ImportFullRepositoryForm: FC = ({ setFile, setValid, password, setPassword }) => { const [t] = useTranslation("repos"); return (
-
+
{ @@ -46,6 +48,15 @@ const ImportFullRepositoryForm: FC = ({ setFile, setValid}) => { }} />
+
+ setPassword(value)} + type="password" + label={t("import.bundle.password.title")} + helpText={t("import.bundle.password.helpText")} + /> +
); }; diff --git a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx index fb4d3fb753..0f449605d6 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx @@ -45,12 +45,12 @@ const ImportRepositoryFromBundle: FC = ({ 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(); const [file, setFile] = useState(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 = ({ 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 = ({ url, repositoryType, setImportP setValid={(file: boolean) => setValid({ ...valid, file })} compressed={compressed} setCompressed={setCompressed} + password={password} + setPassword={setPassword} disabled={loading} />
diff --git a/scm-ui/ui-webapp/src/repos/components/ImportTypeSelect.tsx b/scm-ui/ui-webapp/src/repos/components/ImportTypeSelect.tsx index 8978b8f5e8..d0c64a1f38 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportTypeSelect.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportTypeSelect.tsx @@ -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 = ({ repositoryType, importType, setImportType, disabled }) => { const [t] = useTranslation("repos"); @@ -45,18 +52,20 @@ const ImportTypeSelect: FC = ({ repositoryType, importType, setImportType return ( <> - {(repositoryType._links.import as Link[]).map((type, index) => ( - - ))} + + {(repositoryType._links.import as Link[]).map((type, index) => ( + + ))} + ); }; diff --git a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx index 0b8fc1d5fc..5bf8fd4250 100644 --- a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx @@ -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 = ({ repository }) => { - + {repository._links.exportInfo && } diff --git a/scm-ui/ui-webapp/src/repos/containers/ExportRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/ExportRepository.tsx index d3de37f35d..99e1736eff 100644 --- a/scm-ui/ui-webapp/src/repos/containers/ExportRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/ExportRepository.tsx @@ -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 {t("export.exportInfo.interrupted")}; +}; + +const ExportInfoBox: FC<{ exportInfo: ExportInfo }> = ({ exportInfo }) => { + const [t] = useTranslation("repos"); + return ( + + {t("export.exportInfo.infoBoxTitle")} +

{t("export.exportInfo.exporter", { username: exportInfo.exporterName })}

+

+ {t("export.exportInfo.created")} + +

+
+

{exportInfo.withMetadata ? t("export.exportInfo.repositoryArchive") : t("export.exportInfo.repository")}

+ {exportInfo.encrypted && ( + <> +
+

{t("export.exportInfo.encrypted")}

+ + )} +
+ ); +}; + const ExportRepository: FC = ({ 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 ; + } else { + return ; + } + }; + return ( <>
- - {t("export.notification")} - - <> + + + {t("export.notification")} + + {repository?._links?.fullExport && ( - {repository?._links?.fullExport && ( - + {encrypt && ( +
+ - )} - setLoading(true)}> -
+ )} + {renderExportInfo()} + + +