diff --git a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java index 87209ce409..3b64e6b5ac 100644 --- a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java +++ b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java @@ -127,7 +127,7 @@ public class AuthenticationFilter extends HttpFilter logger.trace("user is already authenticated"); processChain(request, response, chain, subject); } - else if (isAnonymousAccessEnabled()) + else if (isAnonymousAccessEnabled() && !HttpUtil.isWUIRequest(request)) { logger.trace("anonymous access granted"); subject.login(new AnonymousToken()); diff --git a/scm-it/src/test/java/sonia/scm/it/DiffITCase.java b/scm-it/src/test/java/sonia/scm/it/DiffITCase.java index acd2816422..a9d99deab2 100644 --- a/scm-it/src/test/java/sonia/scm/it/DiffITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/DiffITCase.java @@ -1,6 +1,7 @@ package sonia.scm.it; import org.apache.http.HttpStatus; +import org.assertj.core.api.AbstractCharSequenceAssert; import org.assertj.core.util.Lists; import org.junit.Before; import org.junit.Ignore; @@ -28,6 +29,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static sonia.scm.it.utils.RestUtil.ADMIN_PASSWORD; import static sonia.scm.it.utils.RestUtil.ADMIN_USERNAME; @@ -94,8 +96,7 @@ public class DiffITCase { String gitDiff = getDiff(RepositoryUtil.createAndCommitFile(gitRepositoryClient, ADMIN_USERNAME, "a.txt", "content of a"), gitRepositoryResponse); String expected = getGitDiffWithoutIndexLine(gitDiff); - assertThat(svnDiff) - .isEqualTo(expected); + assertDiffsAreEqual(svnDiff, expected); } @Test @@ -107,8 +108,7 @@ public class DiffITCase { String gitDiff = getDiff(RepositoryUtil.removeAndCommitFile(gitRepositoryClient, ADMIN_USERNAME, "a.txt"), gitRepositoryResponse); String expected = getGitDiffWithoutIndexLine(gitDiff); - assertThat(svnDiff) - .isEqualTo(expected); + assertDiffsAreEqual(svnDiff, expected); } @Test @@ -120,8 +120,7 @@ public class DiffITCase { String gitDiff = getDiff(RepositoryUtil.updateAndCommitFile(gitRepositoryClient, ADMIN_USERNAME, "a.txt", "the updated content of a"), gitRepositoryResponse); String expected = getGitDiffWithoutIndexLine(gitDiff); - assertThat(svnDiff) - .isEqualTo(expected); + assertDiffsAreEqual(svnDiff, expected); } @Test @@ -161,21 +160,17 @@ public class DiffITCase { String fileContent = getFileContent("/diff/largefile/original/SvnDiffGenerator_forTest"); String svnDiff = getDiff(RepositoryUtil.updateAndCommitFile(svnRepositoryClient, ADMIN_USERNAME, fileName, fileContent), svnRepositoryResponse); String gitDiff = getDiff(RepositoryUtil.updateAndCommitFile(gitRepositoryClient, ADMIN_USERNAME, fileName, fileContent), gitRepositoryResponse); - assertThat(svnDiff) - .isEqualTo(getGitDiffWithoutIndexLine(gitDiff)); + assertDiffsAreEqual(svnDiff, getGitDiffWithoutIndexLine(gitDiff)); fileContent = getFileContent("/diff/largefile/modified/v1/SvnDiffGenerator_forTest"); svnDiff = getDiff(RepositoryUtil.updateAndCommitFile(svnRepositoryClient, ADMIN_USERNAME, fileName, fileContent), svnRepositoryResponse); gitDiff = getDiff(RepositoryUtil.updateAndCommitFile(gitRepositoryClient, ADMIN_USERNAME, fileName, fileContent), gitRepositoryResponse); - assertThat(svnDiff) - .isEqualTo(getGitDiffWithoutIndexLine(gitDiff)); + assertDiffsAreEqual(svnDiff, getGitDiffWithoutIndexLine(gitDiff)); fileContent = getFileContent("/diff/largefile/modified/v2/SvnDiffGenerator_forTest"); svnDiff = getDiff(RepositoryUtil.updateAndCommitFile(svnRepositoryClient, ADMIN_USERNAME, fileName, fileContent), svnRepositoryResponse); gitDiff = getDiff(RepositoryUtil.updateAndCommitFile(gitRepositoryClient, ADMIN_USERNAME, fileName, fileContent), gitRepositoryResponse); - assertThat(svnDiff) - .isEqualTo(getGitDiffWithoutIndexLine(gitDiff)); - + assertDiffsAreEqual(svnDiff, getGitDiffWithoutIndexLine(gitDiff)); } /** @@ -196,8 +191,7 @@ public class DiffITCase { Changeset commit1 = RepositoryUtil.addFileAndCommit(gitRepositoryClient, fileName, ADMIN_USERNAME, ""); String svnDiff = getDiff(commit, svnRepositoryResponse); String gitDiff = getDiff(commit1, gitRepositoryResponse); - assertThat(svnDiff) - .isEqualTo(getGitDiffWithoutIndexLine(gitDiff)); + assertDiffsAreEqual(svnDiff, getGitDiffWithoutIndexLine(gitDiff)); } @@ -218,8 +212,7 @@ public class DiffITCase { String gitDiff = getDiff(RepositoryUtil.addFileAndCommit(gitRepositoryClient, newFileName, ADMIN_USERNAME, "renamed file"), gitRepositoryResponse); String expected = getGitDiffWithoutIndexLine(gitDiff); - assertThat(svnDiff) - .isEqualTo(expected); + assertDiffsAreEqual(svnDiff, expected); } public String getFileContent(String name) throws URISyntaxException, IOException { @@ -242,6 +235,12 @@ public class DiffITCase { return gitDiff.replaceAll(".*(index.*\n)", ""); } + private void assertDiffsAreEqual(String svnDiff, String gitDiff) { + assertThat(svnDiff) + .as("diffs are different\n\nsvn:\n==================================================\n\n%s\n\ngit:\n==================================================\n\n%s)", svnDiff, gitDiff) + .isEqualTo(gitDiff); + } + private String getDiff(Changeset svnChangeset, ScmRequests.RepositoryResponse svnRepositoryResponse) { return svnRepositoryResponse.requestChangesets() .requestDiffInGitFormat(svnChangeset.getId()) diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java index 5ec69cccdd..2048d13dea 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java @@ -213,8 +213,14 @@ public class GitBrowseCommand extends AbstractGitCommand if (lfsPointer.isPresent()) { BlobStore lfsBlobStore = lfsBlobStoreFactory.getLfsBlobStore(repository); - Blob blob = lfsBlobStore.get(lfsPointer.get().getOid().getName()); - file.setLength(blob.getSize()); + String oid = lfsPointer.get().getOid().getName(); + Blob blob = lfsBlobStore.get(oid); + if (blob == null) { + logger.error("lfs blob for lob id {} not found in lfs store of repository {}", oid, repository.getNamespaceAndName()); + file.setLength(-1); + } else { + file.setLength(blob.getSize()); + } } else { file.setLength(loader.getSize()); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java index 35ff4d6ac2..193ab5bbc8 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java @@ -145,7 +145,12 @@ public class GitCatCommand extends AbstractGitCommand implements CatCommand { private Loader loadFromLfsStore(TreeWalk treeWalk, RevWalk revWalk, LfsPointer lfsPointer) throws IOException { BlobStore lfsBlobStore = lfsBlobStoreFactory.getLfsBlobStore(repository); - Blob blob = lfsBlobStore.get(lfsPointer.getOid().getName()); + String oid = lfsPointer.getOid().getName(); + Blob blob = lfsBlobStore.get(oid); + if (blob == null) { + logger.error("lfs blob for lob id {} not found in lfs store of repository {}", oid, repository.getNamespaceAndName()); + throw notFound(entity("LFS", oid).in(repository)); + } GitUtil.release(revWalk); GitUtil.release(treeWalk); return new BlobLoader(blob); diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java index 1ac64c1b5e..dce4d0622f 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java @@ -33,13 +33,18 @@ package sonia.scm.repository.spi; import org.eclipse.jgit.diff.DiffEntry; import org.eclipse.jgit.diff.DiffFormatter; +import org.eclipse.jgit.util.QuotedString; import sonia.scm.repository.Repository; import sonia.scm.repository.api.DiffCommandBuilder; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.OutputStream; + +import static java.nio.charset.StandardCharsets.UTF_8; /** - * * @author Sebastian Sdorra */ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand { @@ -51,12 +56,12 @@ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand { @Override public DiffCommandBuilder.OutputStreamConsumer getDiffResult(DiffCommandRequest request) throws IOException { @SuppressWarnings("squid:S2095") // repository will be closed with the RepositoryService - org.eclipse.jgit.lib.Repository repository = open(); + org.eclipse.jgit.lib.Repository repository = open(); Differ.Diff diff = Differ.diff(repository, request); return output -> { - try (DiffFormatter formatter = new DiffFormatter(output)) { + try (DiffFormatter formatter = new DiffFormatter(new DequoteOutputStream(output))) { formatter.setRepository(repository); for (DiffEntry e : diff.getEntries()) { @@ -70,4 +75,116 @@ public class GitDiffCommand extends AbstractGitCommand implements DiffCommand { }; } + static class DequoteOutputStream extends OutputStream { + + private static final String[] DEQUOTE_STARTS = { + "--- ", + "+++ ", + "diff --git " + }; + + private final OutputStream target; + + private boolean afterNL = true; + private boolean writeToBuffer = false; + private int numberOfPotentialBeginning = -1; + private int potentialBeginningCharCount = 0; + private boolean inPotentialQuotedLine = false; + + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + DequoteOutputStream(OutputStream target) { + this.target = new BufferedOutputStream(target); + } + + @Override + public void write(int i) throws IOException { + if (i == (int) '\n') { + handleNewLine(i); + return; + } + + if (afterNL) { + afterNL = false; + if (foundPotentialBeginning(i)) { + return; + } + numberOfPotentialBeginning = -1; + inPotentialQuotedLine = false; + } + + if (inPotentialQuotedLine && i == '"') { + handleQuote(); + return; + } + + if (numberOfPotentialBeginning > -1 && checkForFurtherBeginning(i)) { + return; + } + + if (writeToBuffer) { + buffer.write(i); + } else { + target.write(i); + } + } + + private boolean checkForFurtherBeginning(int i) throws IOException { + if (i == DEQUOTE_STARTS[numberOfPotentialBeginning].charAt(potentialBeginningCharCount)) { + if (potentialBeginningCharCount + 1 < DEQUOTE_STARTS[numberOfPotentialBeginning].length()) { + ++potentialBeginningCharCount; + } else { + inPotentialQuotedLine = true; + } + target.write(i); + return true; + } else { + numberOfPotentialBeginning = -1; + } + return false; + } + + private boolean foundPotentialBeginning(int i) throws IOException { + for (int n = 0; n < DEQUOTE_STARTS.length; ++n) { + if (i == DEQUOTE_STARTS[n].charAt(0)) { + numberOfPotentialBeginning = n; + potentialBeginningCharCount = 1; + target.write(i); + return true; + } + } + return false; + } + + private void handleQuote() throws IOException { + if (writeToBuffer) { + buffer.write('"'); + dequoteBuffer(); + } else { + writeToBuffer = true; + buffer.reset(); + buffer.write('"'); + } + } + + private void handleNewLine(int i) throws IOException { + afterNL = true; + if (writeToBuffer) { + dequoteBuffer(); + } + target.write(i); + } + + private void dequoteBuffer() throws IOException { + byte[] bytes = buffer.toByteArray(); + String dequote = QuotedString.GIT_PATH.dequote(bytes, 0, bytes.length); + target.write(dequote.getBytes(UTF_8)); + writeToBuffer = false; + } + + @Override + public void flush() throws IOException { + target.flush(); + } + } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommand_DequoteOutputStreamTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommand_DequoteOutputStreamTest.java new file mode 100644 index 0000000000..6067356a09 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffCommand_DequoteOutputStreamTest.java @@ -0,0 +1,35 @@ +package sonia.scm.repository.spi; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +public class GitDiffCommand_DequoteOutputStreamTest { + + @Test + void shouldDequoteText() throws IOException { + String s = "diff --git \"a/file \\303\\272\\303\\274\\303\\276\\303\\253\\303\\251\\303\\245\\303\\253\\303\\245\\303\\251 a\" \"b/file \\303\\272\\303\\274\\303\\276\\303\\253\\303\\251\\303\\245\\303\\253\\303\\245\\303\\251 b\"\n" + + "new file mode 100644\n" + + "index 0000000..8cb0607\n" + + "--- /dev/null\n" + + "+++ \"b/\\303\\272\\303\\274\\303\\276\\303\\253\\303\\251\\303\\245\\303\\253\\303\\245\\303\\251 \\303\\245g\\303\\260f\\303\\237\"\n" + + "@@ -0,0 +1 @@\n" + + "+String s = \"quotes shall be kept\";"; + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + GitDiffCommand.DequoteOutputStream stream = new GitDiffCommand.DequoteOutputStream(buffer); + byte[] bytes = s.getBytes(); + stream.write(bytes, 0, bytes.length); + stream.flush(); + + Assertions.assertThat(buffer.toString()).isEqualTo("diff --git a/file úüþëéåëåé a b/file úüþëéåëåé b\n" + + "new file mode 100644\n" + + "index 0000000..8cb0607\n" + + "--- /dev/null\n" + + "+++ b/úüþëéåëåé ågðfß\n" + + "@@ -0,0 +1 @@\n" + + "+String s = \"quotes shall be kept\";"); + } +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java index 48772ad5e5..b3e8e89a5f 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java @@ -77,7 +77,9 @@ public class HgBrowseCommand extends AbstractCommand implements BrowseCommand String revision = MoreObjects.firstNonNull(request.getRevision(), "tip"); Changeset c = LogCommand.on(getContext().open()).rev(revision).limit(1).single(); - cmd.rev(c.getNode()); + if (c != null) { + cmd.rev(c.getNode()); + } if (!Strings.isNullOrEmpty(request.getPath())) { @@ -100,6 +102,6 @@ public class HgBrowseCommand extends AbstractCommand implements BrowseCommand } FileObject file = cmd.execute(); - return new BrowserResult(c.getNode(), revision, file); + return new BrowserResult(c == null? "tip": c.getNode(), revision, file); } } diff --git a/scm-ui/ui-components/src/apiclient.test.ts b/scm-ui/ui-components/src/apiclient.test.ts index 93f535728f..871d8089b7 100644 --- a/scm-ui/ui-components/src/apiclient.test.ts +++ b/scm-ui/ui-components/src/apiclient.test.ts @@ -1,4 +1,4 @@ -import { apiClient, createUrl } from "./apiclient"; +import { apiClient, createUrl, extractXsrfTokenFromCookie } from "./apiclient"; import fetchMock from "fetch-mock"; import { BackendError } from "./errors"; @@ -70,3 +70,22 @@ describe("error handling tests", () => { }); }); }); + +describe("extract xsrf token", () => { + it("should return undefined if no cookie exists", () => { + const token = extractXsrfTokenFromCookie(undefined); + expect(token).toBeUndefined(); + }); + + it("should return undefined without X-Bearer-Token exists", () => { + const token = extractXsrfTokenFromCookie("a=b; c=d; e=f"); + expect(token).toBeUndefined(); + }); + + it("should return xsrf token", () => { + const cookie = + "a=b; X-Bearer-Token=eyJhbGciOiJIUzI1NiJ9.eyJ4c3JmIjoiYjE0NDRmNWEtOWI5Mi00ZDA0LWFkMzMtMTAxYjY3MWQ1YTc0Iiwic3ViIjoic2NtYWRtaW4iLCJqdGkiOiI2RFJpQVphNWwxIiwiaWF0IjoxNTc0MDcyNDQ4LCJleHAiOjE1NzQwNzYwNDgsInNjbS1tYW5hZ2VyLnJlZnJlc2hFeHBpcmF0aW9uIjoxNTc0MTE1NjQ4OTU5LCJzY20tbWFuYWdlci5wYXJlbnRUb2tlbklkIjoiNkRSaUFaYTVsMSJ9.VUJtKeWUn3xtHCEbG51r7ceXZ8CF3cmN8J-eb9EDY_U; c=d"; + const token = extractXsrfTokenFromCookie(cookie); + expect(token).toBe("b1444f5a-9b92-4d04-ad33-101b671d5a74"); + }); +}); diff --git a/scm-ui/ui-components/src/apiclient.ts b/scm-ui/ui-components/src/apiclient.ts index 396200f1c1..3fb03b067f 100644 --- a/scm-ui/ui-components/src/apiclient.ts +++ b/scm-ui/ui-components/src/apiclient.ts @@ -2,13 +2,46 @@ import { contextPath } from "./urls"; import { createBackendError, ForbiddenError, isBackendError, UnauthorizedError } from "./errors"; import { BackendErrorContent } from "./errors"; +const extractXsrfTokenFromJwt = (jwt: string) => { + const parts = jwt.split("."); + if (parts.length === 3) { + return JSON.parse(atob(parts[1])).xsrf; + } +}; + +// @VisibleForTesting +export const extractXsrfTokenFromCookie = (cookieString?: string) => { + if (cookieString) { + const cookies = cookieString.split(";"); + for (const c of cookies) { + const parts = c.trim().split("="); + if (parts[0] === "X-Bearer-Token") { + return extractXsrfTokenFromJwt(parts[1]); + } + } + } +}; + +const extractXsrfToken = () => { + return extractXsrfTokenFromCookie(document.cookie); +}; + const applyFetchOptions: (p: RequestInit) => RequestInit = o => { - o.credentials = "same-origin"; - o.headers = { + const headers: { [key: string]: string } = { Cache: "no-cache", // identify the request as ajax request - "X-Requested-With": "XMLHttpRequest" + "X-Requested-With": "XMLHttpRequest", + // identify the web interface + "X-SCM-Client": "WUI" }; + + const xsrf = extractXsrfToken(); + if (xsrf) { + headers["X-XSRF-Token"] = xsrf; + } + + o.credentials = "same-origin"; + o.headers = headers; return o; }; @@ -47,23 +80,24 @@ class ApiClient { return fetch(createUrl(url), applyFetchOptions({})).then(handleFailure); } - post(url: string, payload: any, contentType = "application/json") { - return this.httpRequestWithJSONBody("POST", url, contentType, payload); + post(url: string, payload?: any, contentType = "application/json", additionalHeaders = new Headers()) { + return this.httpRequestWithJSONBody("POST", url, contentType, additionalHeaders, payload); } - postBinary(url: string, fileAppender: (p: FormData) => void) { + postBinary(url: string, fileAppender: (p: FormData) => void, additionalHeaders = new Headers()) { const formData = new FormData(); fileAppender(formData); const options: RequestInit = { method: "POST", - body: formData + body: formData, + headers: additionalHeaders }; return this.httpRequestWithBinaryBody(options, url); } - put(url: string, payload: any, contentType = "application/json") { - return this.httpRequestWithJSONBody("PUT", url, contentType, payload); + put(url: string, payload: any, contentType = "application/json", additionalHeaders = new Headers()) { + return this.httpRequestWithJSONBody("PUT", url, contentType, additionalHeaders, payload); } head(url: string) { @@ -82,11 +116,20 @@ class ApiClient { return fetch(createUrl(url), options).then(handleFailure); } - httpRequestWithJSONBody(method: string, url: string, contentType: string, payload: any): Promise { + httpRequestWithJSONBody( + method: string, + url: string, + contentType: string, + additionalHeaders: Headers, + payload?: any + ): Promise { const options: RequestInit = { method: method, - body: JSON.stringify(payload) + headers: additionalHeaders }; + if (payload) { + options.body = JSON.stringify(payload); + } return this.httpRequestWithBinaryBody(options, url, contentType); } diff --git a/scm-ui/ui-components/src/buttons/Button.tsx b/scm-ui/ui-components/src/buttons/Button.tsx index 8c1004ac52..3507ba7a06 100644 --- a/scm-ui/ui-components/src/buttons/Button.tsx +++ b/scm-ui/ui-components/src/buttons/Button.tsx @@ -18,6 +18,7 @@ export type ButtonProps = { type Props = ButtonProps & RouteComponentProps & { + title?: string; type?: "button" | "submit" | "reset"; color?: string; }; @@ -38,7 +39,19 @@ class Button extends React.Component { }; render() { - const { label, loading, disabled, type, color, className, icon, fullWidth, reducedMobile, children } = this.props; + const { + label, + title, + loading, + disabled, + type, + color, + className, + icon, + fullWidth, + reducedMobile, + children + } = this.props; const loadingClass = loading ? "is-loading" : ""; const fullWidthClass = fullWidth ? "is-fullwidth" : ""; const reducedMobileClass = reducedMobile ? "is-reduced-mobile" : ""; @@ -46,6 +59,7 @@ class Button extends React.Component { return (