diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a3376621..090eb9b77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +- Add repository import via dump file for Subversion ([#1471](https://github.com/scm-manager/scm-manager/pull/1471)) - Add support for permalinks to lines in source code view ([#1472](https://github.com/scm-manager/scm-manager/pull/1472)) ## [2.11.1] - 2020-12-07 - ### Fixed - Initialization of new git repository with master set as default branch ([#1467](https://github.com/scm-manager/scm-manager/issues/1467) and [#1470](https://github.com/scm-manager/scm-manager/pull/1470)) diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/UnbundleCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/UnbundleCommandRequest.java index 31a99ad887..202fee4a5b 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/UnbundleCommandRequest.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/UnbundleCommandRequest.java @@ -89,7 +89,7 @@ public final class UnbundleCommandRequest * * @return {@link ByteSource} archive */ - ByteSource getArchive() + public ByteSource getArchive() { return archive; } diff --git a/scm-ui/ui-components/src/forms/FileUpload.tsx b/scm-ui/ui-components/src/forms/FileUpload.tsx new file mode 100644 index 0000000000..1a7dafcded --- /dev/null +++ b/scm-ui/ui-components/src/forms/FileUpload.tsx @@ -0,0 +1,64 @@ +/* + * 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. + */ + +import React, { FC, useState, ChangeEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { File } from "@scm-manager/ui-types"; + +type Props = { + handleFile: (file: File) => void; +}; + +const FileUpload: FC = ({ handleFile }) => { + const [t] = useTranslation("commons"); + const [file, setFile] = useState(null); + + return ( +
+ +
+ ); +}; + +export default FileUpload; diff --git a/scm-ui/ui-components/src/forms/index.ts b/scm-ui/ui-components/src/forms/index.ts index de64d40aae..49f2eb2dc6 100644 --- a/scm-ui/ui-components/src/forms/index.ts +++ b/scm-ui/ui-components/src/forms/index.ts @@ -38,3 +38,4 @@ export { default as Textarea } from "./Textarea"; export { default as PasswordConfirmation } from "./PasswordConfirmation"; export { default as LabelWithHelpIcon } from "./LabelWithHelpIcon"; export { default as DropDown } from "./DropDown"; +export { default as FileUpload } from "./FileUpload"; diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index 3fbbe0e3b2..fb9f17c2cb 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -110,5 +110,8 @@ }, "commaSeparatedList": { "lastDivider": "und" + }, + "fileUpload": { + "label": "Datei hochladen" } } diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 599da7704d..ed4feb7dc6 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -66,6 +66,14 @@ "importUrl": "Remote Repository URL", "username": "Benutzername", "password": "Passwort", + "compressed": { + "label": "Komprimiert", + "helpText": "Anwählen, wenn die Datei komprimiert ist." + }, + "bundle": { + "title": "Wählen Sie Ihre Datei aus", + "helpText": "Wählen Sie die Datei aus der das Repository importiert werden soll." + }, "pending": { "subtitle": "Repository wird importiert...", "infoText": "Ihr Repository wird gerade importiert. Dies kann einen Moment dauern. Sie werden weitergeleitet, sobald der Import abgeschlossen ist. Wenn Sie diese Seite verlassen, können Sie nicht zurückkehren, um den Import-Status zu erfahren." @@ -75,6 +83,10 @@ "url": { "label": "Import via URL", "helpText": "Das Repository wird über eine URL importiert." + }, + "bundle": { + "label": "Import aus Dump", + "helpText": "Das Repository wird aus einen Datei Dump importiert." } } }, diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index ac98e39183..9ed828035f 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -111,5 +111,8 @@ }, "commaSeparatedList": { "lastDivider": "and" + }, + "fileUpload": { + "label": "Upload file" } } diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index abebf154d8..9be2a90324 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -67,6 +67,14 @@ "importUrl": "Remote repository url", "username": "Username", "password": "Password", + "compressed": { + "label": "Compressed", + "helpText": "Check if your dump file is compressed." + }, + "bundle": { + "title": "Select your dump file", + "helpText": "Select your dump file from which the repository should be imported." + }, "pending": { "subtitle": "Importing Repository...", "infoText": "Your repository is currently being imported. This may take a moment. You will be forwarded as soon as the import is finished. If you leave this page you cannot return to find out the import status." @@ -76,6 +84,10 @@ "url": { "label": "Import via URL", "helpText": "The Repository will be imported via the provided URL." + }, + "bundle": { + "label": "Import from dump", + "helpText": "The repository will be imported from a dump file." } } }, @@ -198,7 +210,7 @@ "sources": "Sources" }, "parents": { - "label" : "Parent", + "label": "Parent", "label_plural": "Parents" }, "contributors": { diff --git a/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx b/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx new file mode 100644 index 0000000000..ba5da91934 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx @@ -0,0 +1,66 @@ +/* + * 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. + */ + +import React, { FC } from "react"; +import { FileUpload, LabelWithHelpIcon, Checkbox } 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; + compressed: boolean; + setCompressed: (compressed: boolean) => void; + disabled: boolean; +}; + +const ImportFromBundleForm: FC = ({ setFile, setValid, compressed, setCompressed, disabled }) => { + const [t] = useTranslation("repos"); + + return ( +
+
+ + { + setFile(file); + setValid(!!file); + }} + /> +
+
+ setCompressed(value)} + label={t("import.compressed.label")} + disabled={disabled} + helpText={t("import.compressed.helpText")} + title={t("import.compressed.label")} + /> +
+
+ ); +}; + +export default ImportFromBundleForm; diff --git a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx new file mode 100644 index 0000000000..e28eb0b894 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx @@ -0,0 +1,120 @@ +/* + * 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. + */ +import React, { FC, FormEvent, useState } from "react"; +import NamespaceAndNameFields from "./NamespaceAndNameFields"; +import { File, Repository } from "@scm-manager/ui-types"; +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"; + +type Props = { + url: string; + repositoryType: string; + setImportPending: (pending: boolean) => void; +}; + +const ImportRepositoryFromBundle: FC = ({ url, repositoryType, setImportPending }) => { + const [repo, setRepo] = useState({ + name: "", + namespace: "", + type: repositoryType, + contact: "", + description: "", + _links: {}, + }); + + 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 history = useHistory(); + const [t] = useTranslation("repos"); + + const handleImportLoading = (loading: boolean) => { + setImportPending(loading); + setLoading(loading); + }; + + const isValid = () => Object.values(valid).every((v) => v); + + const submit = (event: FormEvent) => { + event.preventDefault(); + const currentPath = history.location.pathname; + setError(undefined); + handleImportLoading(true); + apiClient + .postBinary(compressed ? url + "?compressed=true" : url, (formData) => { + formData.append("bundle", file, file?.name); + formData.append("repository", JSON.stringify(repo)); + }) + .then((response) => { + const location = response.headers.get("Location"); + return apiClient.get(location!); + }) + .then((response) => response.json()) + .then((repo) => { + if (history.location.pathname === currentPath) { + history.push(`/repo/${repo.namespace}/${repo.name}/code/sources`); + } + }) + .catch((error) => { + setError(error); + handleImportLoading(false); + }); + }; + + return ( +
+ + setValid({ ...valid, file })} + compressed={compressed} + setCompressed={setCompressed} + disabled={loading} + /> +
+ >} + setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })} + disabled={loading} + /> + >} + disabled={loading} + setValid={(contact: boolean) => setValid({ ...valid, contact })} + /> + } + /> + + ); +}; + +export default ImportRepositoryFromBundle; diff --git a/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx index b77ed1925b..730f207ec3 100644 --- a/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx @@ -35,10 +35,11 @@ import { fetchRepositoryTypesIfNeeded, getFetchRepositoryTypesFailure, getRepositoryTypes, - isFetchRepositoryTypesPending + isFetchRepositoryTypesPending, } from "../modules/repositoryTypes"; import { connect } from "react-redux"; import { fetchNamespaceStrategiesIfNeeded } from "../../admin/modules/namespaceStrategies"; +import ImportRepositoryFromBundle from "../components/ImportRepositoryFromBundle"; type Props = { repositoryTypes: RepositoryType[]; @@ -67,7 +68,7 @@ const ImportRepository: FC = ({ pageLoading, error, fetchRepositoryTypesIfNeeded, - fetchNamespaceStrategiesIfNeeded + fetchNamespaceStrategiesIfNeeded, }) => { const [importPending, setImportPending] = useState(false); const [repositoryType, setRepositoryType] = useState(); @@ -95,6 +96,16 @@ const ImportRepository: FC = ({ ); } + if (importType === "bundle") { + return ( + link.name === "bundle") as Link).href} + repositoryType={repositoryType!.name} + setImportPending={setImportPending} + /> + ); + } + throw new Error("Unknown import type"); }; @@ -139,7 +150,7 @@ const mapStateToProps = (state: any) => { return { repositoryTypes, pageLoading, - error + error, }; }; @@ -150,7 +161,7 @@ const mapDispatchToProps = (dispatch: any) => { }, fetchNamespaceStrategiesIfNeeded: () => { dispatch(fetchNamespaceStrategiesIfNeeded()); - } + }, }; }; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportResource.java index e06c23528d..e48ad3d000 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportResource.java @@ -24,8 +24,13 @@ package sonia.scm.api.v2.resources; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import com.google.common.io.ByteSource; +import com.google.common.io.Files; import com.google.inject.Inject; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; @@ -37,6 +42,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.apache.shiro.SecurityUtils; +import org.jboss.resteasy.plugins.providers.multipart.InputPart; +import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput; +import org.jboss.resteasy.plugins.providers.multipart.MultipartInputImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.HandlerEventType; @@ -54,6 +62,7 @@ import sonia.scm.repository.api.Command; import sonia.scm.repository.api.PullCommandBuilder; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.util.IOUtil; import sonia.scm.util.ValidationUtil; import sonia.scm.web.VndMediaType; @@ -62,18 +71,28 @@ import javax.validation.constraints.Email; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Pattern; import javax.ws.rs.Consumes; +import javax.ws.rs.DefaultValue; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; +import javax.ws.rs.QueryParam; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.net.URI; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Collections.singletonList; public class RepositoryImportResource { @@ -188,6 +207,139 @@ public class RepositoryImportResource { }; } + /** + * Imports a external repository via dump. The method can + * only be used, if the repository type supports the {@link Command#UNBUNDLE}. The + * method will return a location header with the url to the imported + * repository. + * + * @param uriInfo uri info + * @param type repository type + * @param input multi part form data which should contain a valid repository dto and the input stream of the bundle + * @param compressed true if the bundle is gzip compressed + * @return empty response with location header which points to the imported + * repository + * @since 2.12.0 + */ + @POST + @Path("{type}/bundle") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Import repository from bundle", description = "Imports the repository from the provided bundle.", tags = "Repository") + @ApiResponse( + responseCode = "201", + description = "Repository import was successful" + ) + @ApiResponse( + responseCode = "401", + description = "not authenticated / invalid credentials" + ) + @ApiResponse( + responseCode = "403", + description = "not authorized, the current user has no privileges to read the repository" + ) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public Response importFromBundle(@Context UriInfo uriInfo, + @PathParam("type") String type, + MultipartFormDataInput input, + @QueryParam("compressed") @DefaultValue("false") boolean compressed) { + RepositoryPermissions.create().check(); + Repository repository = doImportFromBundle(type, input, compressed); + + return Response.created(URI.create(resourceLinks.repository().self(repository.getNamespace(), repository.getName()))).build(); + } + + /** + * Start bundle import. + * + * @param type repository type + * @param input multi part form data + * @param compressed true if the bundle is gzip compressed + * @return imported repository + */ + private Repository doImportFromBundle(String type, MultipartFormDataInput input, boolean compressed) { + Map> formParts = input.getFormDataMap(); + RepositoryDto repositoryDto = extractFromInputPart(formParts.get("repository"), RepositoryDto.class); + InputStream inputStream = extractFromInputPart(formParts.get("bundle"), InputStream.class); + + checkNotNull(repositoryDto, "repository data is required"); + checkNotNull(inputStream, "bundle inputStream is required"); + checkArgument(!Strings.isNullOrEmpty(repositoryDto.getName()), "request does not contain name of the repository"); + + Type t = type(type); + + checkSupport(t, Command.UNBUNDLE, "bundle"); + + Repository repository = mapper.map(repositoryDto); + repository.setPermissions(singletonList( + new RepositoryPermission(SecurityUtils.getSubject().getPrincipal().toString(), "OWNER", false) + )); + + try { + repository = manager.create( + repository, + unbundleImport(inputStream, compressed) + ); + eventBus.post(new RepositoryImportEvent(HandlerEventType.MODIFY, repository, false)); + + } catch (Exception e) { + eventBus.post(new RepositoryImportEvent(HandlerEventType.MODIFY, repository, true)); + throw e; + } + + return repository; + } + + @VisibleForTesting + Consumer unbundleImport(InputStream inputStream, boolean compressed) { + return repository -> { + File file = null; + try (RepositoryService service = serviceFactory.create(repository)) { + file = File.createTempFile("scm-import-", ".bundle"); + long length = Files.asByteSink(file).writeFrom(inputStream); + logger.info("copied {} bytes to temp, start bundle import", length); + service.getUnbundleCommand().setCompressed(compressed).unbundle(file); + } catch (IOException e) { + throw new InternalRepositoryException(repository, "Failed to import from bundle", e); + } finally { + try { + IOUtil.delete(file); + } catch (IOException ex) { + logger.warn("could not delete temporary file", ex); + } + } + }; + } + + private T extractFromInputPart(List input, Class type) { + try { + if (input != null && !input.isEmpty()) { + if (type == InputStream.class) { + return (T) ((MultipartInputImpl.PartImpl) input.get(0)).getBody(); + } + String content = new ByteSource() { + @Override + public InputStream openStream() throws IOException { + return ((MultipartInputImpl.PartImpl) input.get(0)).getBody(); + } + }.asCharSource(UTF_8).read(); + try (JsonParser parser = new JsonFactory().createParser(content)) { + parser.setCodec(new ObjectMapper()); + return parser.readValueAs(type); + } + } + } catch (IOException ex) { + logger.debug("Could not extract repository from input"); + } + return null; + } + /** * Check repository type for support for the given command. * @@ -241,16 +393,23 @@ public class RepositoryImportResource { interface ImportRepositoryDto { String getNamespace(); + @Pattern(regexp = ValidationUtil.REGEX_REPOSITORYNAME) String getName(); + @NotEmpty String getType(); + @Email String getContact(); + String getDescription(); + @NotEmpty String getImportUrl(); + String getUsername(); + String getPassword(); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java index 7822589dd9..de58b7e153 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java @@ -47,8 +47,13 @@ public abstract class RepositoryTypeToRepositoryTypeDtoMapper extends BaseMapper void appendLinks(RepositoryType repositoryType, @MappingTarget RepositoryTypeDto target) { Links.Builder linksBuilder = linkingTo().self(resourceLinks.repositoryType().self(repositoryType.getName())); - if (RepositoryPermissions.create().isPermitted() && repositoryType.getSupportedCommands().contains(Command.PULL)) { - linksBuilder.array(Link.linkBuilder("import", resourceLinks.repository().importFromUrl(repositoryType.getName())).withName("url").build()); + if (RepositoryPermissions.create().isPermitted()) { + if (repositoryType.getSupportedCommands().contains(Command.PULL)) { + linksBuilder.array(Link.linkBuilder("import", resourceLinks.repository().importFromUrl(repositoryType.getName())).withName("url").build()); + } + if (repositoryType.getSupportedCommands().contains(Command.UNBUNDLE)) { + linksBuilder.array(Link.linkBuilder("import", resourceLinks.repository().importFromBundle(repositoryType.getName())).withName("bundle").build()); + } } target.add(linksBuilder.build()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 553a83ee2e..6aade4f134 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -360,6 +360,10 @@ class ResourceLinks { String importFromUrl(String type) { return repositoryImportLinkBuilder.method("getRepositoryImportResource").parameters().method("importFromUrl").parameters(type).href(); } + + String importFromBundle(String type) { + return repositoryImportLinkBuilder.method("getRepositoryImportResource").parameters().method("importFromBundle").parameters(type).href(); + } } RepositoryCollectionLinks repositoryCollection() { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java index daabb9ae20..52301ef3a9 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java @@ -24,6 +24,8 @@ package sonia.scm.api.v2.resources; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import com.google.common.collect.ImmutableSet; @@ -39,7 +41,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; -import sonia.scm.HandlerEventType; import sonia.scm.PageResult; import sonia.scm.config.ScmConfiguration; import sonia.scm.event.ScmEventBus; @@ -58,17 +59,28 @@ import sonia.scm.repository.api.ImportFailedException; import sonia.scm.repository.api.PullCommandBuilder; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.repository.api.UnbundleCommandBuilder; +import sonia.scm.repository.api.UnbundleResponse; import sonia.scm.user.User; import sonia.scm.web.RestDispatcher; import sonia.scm.web.VndMediaType; import javax.servlet.http.HttpServletResponse; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.Collections; +import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.function.Consumer; import java.util.function.Predicate; @@ -86,6 +98,7 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyObject; import static org.mockito.ArgumentMatchers.anyString; @@ -565,6 +578,101 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { assertThrows(ImportFailedException.class, () -> repositoryConsumer.accept(repository)); } + @Test + public void shouldImportRepositoryFromBundle() throws IOException, URISyntaxException { + when(manager.getHandler("svn")).thenReturn(repositoryHandler); + when(repositoryHandler.getType()).thenReturn(new RepositoryType("svn", "svn", ImmutableSet.of(Command.UNBUNDLE))); + when(repositoryManager.create(any(), any())).thenReturn(RepositoryTestData.createHeartOfGold()); + + RepositoryDto repositoryDto = new RepositoryDto(); + repositoryDto.setName("HeartOfGold"); + repositoryDto.setNamespace("hitchhiker"); + repositoryDto.setType("svn"); + + URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump"); + byte[] svnDump = Resources.toByteArray(dumpUrl); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/svn/bundle"); + MockHttpResponse response = new MockHttpResponse(); + + multipartRequest(request, Collections.singletonMap("bundle", new ByteArrayInputStream(svnDump)), repositoryDto); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_CREATED, response.getStatus()); + assertEquals("/v2/repositories/hitchhiker/HeartOfGold", response.getOutputHeaders().get("Location").get(0).toString()); + ArgumentCaptor event = ArgumentCaptor.forClass(RepositoryImportEvent.class); + verify(eventBus).post(event.capture()); + assertFalse(event.getValue().isFailed()); + } + + @Test + public void shouldThrowFailedEventOnImportRepositoryFromBundle() throws IOException, URISyntaxException { + when(manager.getHandler("svn")).thenReturn(repositoryHandler); + when(repositoryHandler.getType()).thenReturn(new RepositoryType("svn", "svn", ImmutableSet.of(Command.UNBUNDLE))); + doThrow(ImportFailedException.class).when(repositoryManager).create(any(), any()); + + RepositoryDto repositoryDto = new RepositoryDto(); + repositoryDto.setName("HeartOfGold"); + repositoryDto.setNamespace("hitchhiker"); + repositoryDto.setType("svn"); + + URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump"); + byte[] svnDump = Resources.toByteArray(dumpUrl); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/svn/bundle"); + MockHttpResponse response = new MockHttpResponse(); + + multipartRequest(request, Collections.singletonMap("bundle", new ByteArrayInputStream(svnDump)), repositoryDto); + + dispatcher.invoke(request, response); + + assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, response.getStatus()); + ArgumentCaptor event = ArgumentCaptor.forClass(RepositoryImportEvent.class); + verify(eventBus).post(event.capture()); + assertTrue(event.getValue().isFailed()); + } + + @Test + public void shouldImportCompressedBundle() throws IOException { + URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump.gz"); + byte[] svnDump = Resources.toByteArray(dumpUrl); + + UnbundleCommandBuilder ubc = mock(UnbundleCommandBuilder.class, RETURNS_SELF); + when(ubc.unbundle(any(File.class))).thenReturn(new UnbundleResponse(42)); + RepositoryService service = mock(RepositoryService.class); + when(serviceFactory.create(any(Repository.class))).thenReturn(service); + when(service.getUnbundleCommand()).thenReturn(ubc); + InputStream in = new ByteArrayInputStream(svnDump); + + Consumer repositoryConsumer = repositoryImportResource.unbundleImport(in, true); + repositoryConsumer.accept(RepositoryTestData.createHeartOfGold("svn")); + + verify(ubc).setCompressed(true); + verify(ubc).unbundle(any(File.class)); + } + + @Test + public void shouldImportNonCompressedBundle() throws IOException { + URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump"); + byte[] svnDump = Resources.toByteArray(dumpUrl); + + UnbundleCommandBuilder ubc = mock(UnbundleCommandBuilder.class, RETURNS_SELF); + when(ubc.unbundle(any(File.class))).thenReturn(new UnbundleResponse(21)); + RepositoryService service = mock(RepositoryService.class); + when(serviceFactory.create(any(Repository.class))).thenReturn(service); + when(service.getUnbundleCommand()).thenReturn(ubc); + InputStream in = new ByteArrayInputStream(svnDump); + + Consumer repositoryConsumer = repositoryImportResource.unbundleImport(in, false); + repositoryConsumer.accept(RepositoryTestData.createHeartOfGold("svn")); + + verify(ubc, never()).setCompressed(true); + verify(ubc).unbundle(any(File.class)); + } + private PageResult createSingletonPageResult(Repository repository) { return new PageResult<>(singletonList(repository), 0); } @@ -586,4 +694,49 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(repositoryManager.get(id)).thenReturn(repository); return repository; } + + + /** + * This method is a slightly adapted copy of Lin Zaho's gist at https://gist.github.com/lin-zhao/9985191 + */ + private MockHttpRequest multipartRequest(MockHttpRequest request, Map files, RepositoryDto repository) throws IOException { + String boundary = UUID.randomUUID().toString(); + request.contentType("multipart/form-data; boundary=" + boundary); + + //Make sure this is deleted in afterTest() + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (OutputStreamWriter formWriter = new OutputStreamWriter(buffer)) { + formWriter.append("--").append(boundary); + + for (Map.Entry entry : files.entrySet()) { + formWriter.append("\n"); + formWriter.append(String.format("Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"", + entry.getKey(), entry.getKey())).append("\n"); + formWriter.append("Content-Type: application/octet-stream").append("\n\n"); + + InputStream stream = entry.getValue(); + int b = stream.read(); + while (b >= 0) { + formWriter.write(b); + b = stream.read(); + } + stream.close(); + formWriter.append("\n").append("--").append(boundary); + } + + if (repository != null) { + formWriter.append("\n"); + formWriter.append("Content-Disposition: form-data; name=\"repository\"").append("\n\n"); + StringWriter repositoryWriter = new StringWriter(); + new JsonFactory().createGenerator(repositoryWriter).setCodec(new ObjectMapper()).writeObject(repository); + formWriter.append(repositoryWriter.getBuffer().toString()).append("\n"); + formWriter.append("--").append(boundary); + } + + formWriter.append("--"); + formWriter.flush(); + } + request.setInputStream(new ByteArrayInputStream(buffer.toByteArray())); + return request; + } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java index ea49c9a933..0041ef1332 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java @@ -112,4 +112,31 @@ public class RepositoryTypeToRepositoryTypeDtoMapperTest { RepositoryTypeDto dto = mapper.map(type); assertFalse(dto.getLinks().getLinkBy("import").isPresent()); } + + @Test + public void shouldAppendImportFromBundleLink() { + RepositoryType type = new RepositoryType("hk", "Hitchhiker", ImmutableSet.of(Command.UNBUNDLE)); + when(subject.isPermitted("repository:create")).thenReturn(true); + + RepositoryTypeDto dto = mapper.map(type); + assertEquals( + "https://scm-manager.org/scm/v2/repositories/import/hk/bundle", + dto.getLinks().getLinkBy("import").get().getHref() + ); + } + + @Test + public void shouldNotAppendImportFromBundleLinkIfCommandNotSupported() { + when(subject.isPermitted("repository:create")).thenReturn(true); + RepositoryTypeDto dto = mapper.map(type); + assertFalse(dto.getLinks().getLinkBy("import").isPresent()); + } + + @Test + public void shouldNotAppendImportFromBundleLinkIfNotPermitted() { + RepositoryType type = new RepositoryType("hk", "Hitchhiker", ImmutableSet.of(Command.UNBUNDLE)); + + RepositoryTypeDto dto = mapper.map(type); + assertFalse(dto.getLinks().getLinkBy("import").isPresent()); + } } diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump b/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump new file mode 100644 index 0000000000..f339c8635f --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump @@ -0,0 +1,83 @@ +SVN-fs-dump-format-version: 2 + +UUID: dcaa635c-9a8d-4cd6-918f-250ca2f765ea + +Revision-number: 0 +Prop-content-length: 56 +Content-length: 56 + +K 8 +svn:date +V 27 +2020-12-09T13:42:16.879000Z +PROPS-END + +Revision-number: 1 +Prop-content-length: 124 +Content-length: 124 + +K 10 +svn:author +V 8 +scmadmin +K 8 +svn:date +V 27 +2020-12-09T13:42:18.270000Z +K 7 +svn:log +V 21 +initialize repository +PROPS-END + +Node-path: trunk +Node-kind: dir +Node-action: add +Prop-content-length: 10 +Content-length: 10 + +PROPS-END + + +Node-path: trunk/README.md +Node-kind: file +Node-action: add +Text-content-md5: fe6869009516b5517b13036294d05f83 +Text-content-sha1: 4e51754688703c31980541dbbb884671d92cf846 +Prop-content-length: 10 +Text-content-length: 9 +Content-length: 19 + +PROPS-END +# dump_me + +Revision-number: 2 +Prop-content-length: 106 +Content-length: 106 + +K 10 +svn:author +V 8 +scmadmin +K 8 +svn:date +V 27 +2020-12-09T13:42:38.170000Z +K 7 +svn:log +V 4 +test +PROPS-END + +Node-path: trunk/second_one.txt +Node-kind: file +Node-action: add +Text-content-md5: 098f6bcd4621d373cade4e832627b4f6 +Text-content-sha1: a94a8fe5ccb19ba61c4c0873d391e987982fbbd3 +Prop-content-length: 10 +Text-content-length: 4 +Content-length: 14 + +PROPS-END +test + diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump.gz b/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump.gz new file mode 100644 index 0000000000..2ca3731608 Binary files /dev/null and b/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump.gz differ