diff --git a/apiary.apib b/apiary.apib index e89c2c68407..99441af2181 100644 --- a/apiary.apib +++ b/apiary.apib @@ -6,6 +6,31 @@ OpenGrok RESTful API documentation. The following endpoints are accessible under Besides `/suggester` and `/search` endpoints, everything is accessible from `localhost` only. +## Annotation [/annotation{?path}] + +### Get annotation for a file [GET] + ++ Parameters + + path (string) - path of file, relative to source root + ++ Response 200 (application/json) + + Body + + [ + { + "revision": "c55d5891", + "author": "Adam Hornáček", + "description": "changeset: c55d5891\nsummary: Rewrite README.txt to use markdown syntax\nuser: Adam Hornáček \ndate: Wed Aug 30 17:42:12 CEST 2017", + "version": "1/15" + }, + { + "revision": "5e0c6b22", + "author": "Vladimir Kotal", + "description": "changeset: 5e0c6b22\nsummary: bump year\nuser: Vladimir Kotal \ndate: Thu Jul 18 14:43:01 CEST 2019", + "version": "14/15" + } + ] + ## Authorization framework reload [/configuration/authorization/reload] ### reloads authorization framework [POST] diff --git a/opengrok-indexer/src/main/java/org/opengrok/indexer/history/Annotation.java b/opengrok-indexer/src/main/java/org/opengrok/indexer/history/Annotation.java index bfc83070401..fc6c6309a8b 100644 --- a/opengrok-indexer/src/main/java/org/opengrok/indexer/history/Annotation.java +++ b/opengrok-indexer/src/main/java/org/opengrok/indexer/history/Annotation.java @@ -28,7 +28,6 @@ import org.opengrok.indexer.util.Color; import org.opengrok.indexer.util.LazilyInstantiate; import org.opengrok.indexer.util.RainbowColorGenerator; -import org.opengrok.indexer.web.Util; import java.io.IOException; import java.io.StringWriter; @@ -53,7 +52,7 @@ public class Annotation { private static final Logger LOGGER = LoggerFactory.getLogger(Annotation.class); private final List lines = new ArrayList<>(); - private final Map desc = new HashMap<>(); + private final Map desc = new HashMap<>(); // revision to description private final Map fileVersions = new HashMap<>(); // maps revision to file version private final LazilyInstantiate> colors = LazilyInstantiate.using(this::generateColors); private int widestRevision; @@ -163,7 +162,7 @@ void addLine(String revision, String author, boolean enabled) { } void addDesc(String revision, String description) { - desc.put(revision, Util.encode(description)); + desc.put(revision, description); } public String getDesc(String revision) { diff --git a/opengrok-indexer/src/main/java/org/opengrok/indexer/history/GitRepository.java b/opengrok-indexer/src/main/java/org/opengrok/indexer/history/GitRepository.java index 98fdba28e76..1dbcb37789e 100644 --- a/opengrok-indexer/src/main/java/org/opengrok/indexer/history/GitRepository.java +++ b/opengrok-indexer/src/main/java/org/opengrok/indexer/history/GitRepository.java @@ -47,6 +47,7 @@ import org.opengrok.indexer.logger.LoggerFactory; import org.opengrok.indexer.util.BufferSink; import org.opengrok.indexer.util.Executor; +import org.opengrok.indexer.util.HeadHandler; import org.opengrok.indexer.util.StringUtils; import org.opengrok.indexer.util.Version; @@ -389,7 +390,6 @@ private String getFirstRevision(String fullpath) throws IOException { ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK), "rev-list", "--reverse", - "--max-count=1", "HEAD", "--", fullpath @@ -397,23 +397,17 @@ private String getFirstRevision(String fullpath) throws IOException { Executor executor = new Executor(Arrays.asList(argv), new File(getDirectoryName()), RuntimeEnvironment.getInstance().getInteractiveCommandTimeout()); - int status = executor.exec(); - - try (BufferedReader in = new BufferedReader( - new InputStreamReader(executor.getOutputStream()))) { - String line; + HeadHandler headHandler = new HeadHandler(1); + int status = executor.exec(false, headHandler); - if ((line = in.readLine()) != null) { - return line.trim(); - } + String line; + if (headHandler.count() > 0 && (line = headHandler.get(0)) != null) { + return line.trim(); } - if (status != 0) { - LOGGER.log(Level.WARNING, - "Failed to get first revision for: \"{0}\" Exit code: {1}", - new Object[]{fullpath, String.valueOf(status)}); - return null; - } + LOGGER.log(Level.WARNING, + "Failed to get first revision for: \"{0}\" Exit code: {1}", + new Object[]{fullpath, String.valueOf(status)}); return null; } @@ -450,10 +444,10 @@ public Annotation annotate(File file, String revision) throws IOException { cmd.add("--"); cmd.add(getPathRelativeToCanonicalRepositoryRoot(file.getCanonicalPath())); - Executor exec = new Executor(cmd, new File(getDirectoryName()), + Executor executor = new Executor(cmd, new File(getDirectoryName()), RuntimeEnvironment.getInstance().getInteractiveCommandTimeout()); GitAnnotationParser parser = new GitAnnotationParser(file.getName()); - int status = exec.exec(true, parser); + int status = executor.exec(true, parser); // File might have changed its location if it was renamed. // Try to lookup its original name and get the annotation again. @@ -469,10 +463,10 @@ public Annotation annotate(File file, String revision) throws IOException { } cmd.add("--"); cmd.add(findOriginalName(file.getCanonicalPath(), revision)); - exec = new Executor(cmd, new File(getDirectoryName()), + executor = new Executor(cmd, new File(getDirectoryName()), RuntimeEnvironment.getInstance().getInteractiveCommandTimeout()); parser = new GitAnnotationParser(file.getName()); - status = exec.exec(true, parser); + status = executor.exec(true, parser); } if (status != 0) { diff --git a/opengrok-indexer/src/main/java/org/opengrok/indexer/util/HeadHandler.java b/opengrok-indexer/src/main/java/org/opengrok/indexer/util/HeadHandler.java new file mode 100644 index 00000000000..eea7079496e --- /dev/null +++ b/opengrok-indexer/src/main/java/org/opengrok/indexer/util/HeadHandler.java @@ -0,0 +1,109 @@ +/* + * CDDL HEADER START + * + * The contents of this file are subject to the terms of the + * Common Development and Distribution License (the "License"). + * You may not use this file except in compliance with the License. + * + * See LICENSE.txt included in this distribution for the specific + * language governing permissions and limitations under the License. + * + * When distributing Covered Code, include this CDDL HEADER in each + * file and include the License file at LICENSE.txt. + * If applicable, add the following below this CDDL HEADER, with the + * fields enclosed by brackets "[]" replaced with your own identifying + * information: Portions Copyright [yyyy] [name of copyright owner] + * + * CDDL HEADER END + */ + +/* + * Copyright (c) 2020 Oracle and/or its affiliates. All rights reserved. + */ + +package org.opengrok.indexer.util; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * The purpose of this class is to provide {@code StreamHandler} that limits the output + * to specified number of lines. Compared to {@code SpoolHandler} it consumes + * limited amount of heap. + */ +public class HeadHandler implements Executor.StreamHandler { + private final int maxLines; + + private final List lines = new ArrayList<>(); + private final Charset charset; + + private static final int bufferedReaderSize = 200; + + /** + * Charset of the underlying reader is set to UTF-8. + * @param maxLines maximum number of lines to store + */ + public HeadHandler(int maxLines) { + this.maxLines = maxLines; + this.charset = StandardCharsets.UTF_8; + } + + /** + * @param maxLines maximum number of lines to store + * @param charset character set + */ + public HeadHandler(int maxLines, Charset charset) { + this.maxLines = maxLines; + this.charset = charset; + } + + /** + * @return number of lines read + */ + public int count() { + return lines.size(); + } + + /** + * @param index index + * @return line at given index. Will be non {@code null} for valid index. + */ + public String get(int index) { + return lines.get(index); + } + + // for testing + static int getBufferedReaderSize() { + return bufferedReaderSize; + } + + @Override + public void processStream(InputStream input) throws IOException { + try (BufferedInputStream bufStream = new BufferedInputStream(input); + BufferedReader reader = new BufferedReader(new InputStreamReader(bufStream, this.charset), + bufferedReaderSize)) { + int lineNum = 0; + while (lineNum < maxLines) { + String line = reader.readLine(); + if (line == null) { // EOF + return; + } + lines.add(line); + lineNum++; + } + + // Read and forget the rest. + byte[] buf = new byte[1024]; + while ((bufStream.read(buf)) != -1) { + ; + } + } + } +} diff --git a/opengrok-indexer/src/main/java/org/opengrok/indexer/web/Util.java b/opengrok-indexer/src/main/java/org/opengrok/indexer/web/Util.java index 281f14b9099..5275dacefee 100644 --- a/opengrok-indexer/src/main/java/org/opengrok/indexer/web/Util.java +++ b/opengrok-indexer/src/main/java/org/opengrok/indexer/web/Util.java @@ -713,82 +713,88 @@ public static void readableLine(int num, Writer out, Annotation annotation, Stri out.write(closeQuotedTag); out.write(snum); out.write(anchorEnd); + if (annotation != null) { - String r = annotation.getRevision(num); - boolean enabled = annotation.isEnabled(num); - out.write(""); - if (enabled) { - out.write(anchorClassStart); - out.write("r"); - out.write("\" style=\"background-color: "); - out.write(annotation.getColors().getOrDefault(r, "inherit")); - out.write("\" href=\""); - out.write(URIEncode(annotation.getFilename())); - out.write("?a=true&r="); - out.write(URIEncode(r)); - String msg = annotation.getDesc(r); - out.write("\" title=\""); - if (msg != null) { - out.write(msg); - } - if (annotation.getFileVersion(r) != 0) { - out.write("<br/>version: " + annotation.getFileVersion(r) + "/" - + annotation.getRevisions().size()); - } - out.write(closeQuotedTag); - } - StringBuilder buf = new StringBuilder(); - final boolean most_recent_revision = annotation.getFileVersion(r) == annotation.getRevisions().size(); - // print an asterisk for the most recent revision - if (most_recent_revision) { - buf.append(""); - buf.append('*'); + writeAnnotation(num, out, annotation, userPageLink, userPageSuffix, project); + } + } + + private static void writeAnnotation(int num, Writer out, Annotation annotation, String userPageLink, + String userPageSuffix, String project) throws IOException { + String r = annotation.getRevision(num); + boolean enabled = annotation.isEnabled(num); + out.write(""); + if (enabled) { + out.write(anchorClassStart); + out.write("r"); + out.write("\" style=\"background-color: "); + out.write(annotation.getColors().getOrDefault(r, "inherit")); + out.write("\" href=\""); + out.write(URIEncode(annotation.getFilename())); + out.write("?a=true&r="); + out.write(URIEncode(r)); + String msg = annotation.getDesc(r); + out.write("\" title=\""); + if (msg != null) { + out.write(Util.encode(msg)); } - htmlize(r, buf); - if (most_recent_revision) { - buf.append(""); // recent revision span + if (annotation.getFileVersion(r) != 0) { + out.write("<br/>version: " + annotation.getFileVersion(r) + "/" + + annotation.getRevisions().size()); } + out.write(closeQuotedTag); + } + StringBuilder buf = new StringBuilder(); + final boolean most_recent_revision = annotation.getFileVersion(r) == annotation.getRevisions().size(); + // print an asterisk for the most recent revision + if (most_recent_revision) { + buf.append(""); + buf.append('*'); + } + htmlize(r, buf); + if (most_recent_revision) { + buf.append(""); // recent revision span + } + out.write(buf.toString()); + buf.setLength(0); + if (enabled) { + RuntimeEnvironment env = RuntimeEnvironment.getInstance(); + + out.write(anchorEnd); + + // Write link to search the revision in current project. + out.write(anchorClassStart); + out.write("search\" href=\"" + env.getUrlPrefix()); + out.write("defs=&refs=&path="); + out.write(project); + out.write("&hist="" + URIEncode(r) + """); + out.write("&type=\" title=\"Search history for this changeset"); + out.write(closeQuotedTag); + out.write("S"); + out.write(anchorEnd); + } + String a = annotation.getAuthor(num); + if (userPageLink == null) { + out.write(HtmlConsts.SPAN_A); + htmlize(a, buf); out.write(buf.toString()); + out.write(HtmlConsts.ZSPAN); buf.setLength(0); - if (enabled) { - RuntimeEnvironment env = RuntimeEnvironment.getInstance(); - - out.write(anchorEnd); - - // Write link to search the revision in current project. - out.write(anchorClassStart); - out.write("search\" href=\"" + env.getUrlPrefix()); - out.write("defs=&refs=&path="); - out.write(project); - out.write("&hist="" + URIEncode(r) + """); - out.write("&type=\" title=\"Search history for this changeset"); - out.write(closeQuotedTag); - out.write("S"); - out.write(anchorEnd); - } - String a = annotation.getAuthor(num); - if (userPageLink == null) { - out.write(HtmlConsts.SPAN_A); - htmlize(a, buf); - out.write(buf.toString()); - out.write(HtmlConsts.ZSPAN); - buf.setLength(0); - } else { - out.write(anchorClassStart); - out.write("a\" href=\""); - out.write(userPageLink); - out.write(URIEncode(a)); - if (userPageSuffix != null) { - out.write(userPageSuffix); - } - out.write(closeQuotedTag); - htmlize(a, buf); - out.write(buf.toString()); - buf.setLength(0); - out.write(anchorEnd); + } else { + out.write(anchorClassStart); + out.write("a\" href=\""); + out.write(userPageLink); + out.write(URIEncode(a)); + if (userPageSuffix != null) { + out.write(userPageSuffix); } - out.write(""); + out.write(closeQuotedTag); + htmlize(a, buf); + out.write(buf.toString()); + buf.setLength(0); + out.write(anchorEnd); } + out.write(""); } /** diff --git a/opengrok-indexer/src/test/java/org/opengrok/indexer/util/HeadHandlerTest.java b/opengrok-indexer/src/test/java/org/opengrok/indexer/util/HeadHandlerTest.java new file mode 100644 index 00000000000..32f6626330c --- /dev/null +++ b/opengrok-indexer/src/test/java/org/opengrok/indexer/util/HeadHandlerTest.java @@ -0,0 +1,178 @@ +/* + * CDDL HEADER START + * + * The contents of this file are subject to the terms of the + * Common Development and Distribution License (the "License"). + * You may not use this file except in compliance with the License. + * + * See LICENSE.txt included in this distribution for the specific + * language governing permissions and limitations under the License. + * + * When distributing Covered Code, include this CDDL HEADER in each + * file and include the License file at LICENSE.txt. + * If applicable, add the following below this CDDL HEADER, with the + * fields enclosed by brackets "[]" replaced with your own identifying + * information: Portions Copyright [yyyy] [name of copyright owner] + * + * CDDL HEADER END + */ + +/* + * Copyright (c) 2020 Oracle and/or its affiliates. All rights reserved. + */ + +package org.opengrok.indexer.util; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.opengrok.indexer.logger.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test the behavior of the {@code HeadHandler} class. The main aspect to check is that the + * input stream is read whole. + */ +@RunWith(Parameterized.class) +public class HeadHandlerTest { + @Parameterized.Parameters + public static Collection data() { + List tests = new ArrayList<>(); + for (int i = 0; i < ThreadLocalRandom.current().nextInt(4, 8); i++) { + tests.add(new Object[]{ThreadLocalRandom.current().nextInt(2, 10), + ThreadLocalRandom.current().nextInt(1, 10), + HeadHandler.getBufferedReaderSize() * ThreadLocalRandom.current().nextInt(1, 40)}); + } + return tests; + } + + private int lineCnt; + private int headLineCnt; + private int totalCharCount; + + private static final Logger LOGGER = LoggerFactory.getLogger(HeadHandlerTest.class); + + /** + * @param lineCnt number of lines to generate + * @param headLineCnt maximum number of lines to get + */ + public HeadHandlerTest(int lineCnt, int headLineCnt, int totalCharCount) { + this.lineCnt = lineCnt; + this.headLineCnt = headLineCnt; + this.totalCharCount = totalCharCount; + } + + private static class RandomInputStream extends InputStream { + private final int maxCharCount; + private int charCount; + private final int maxLines; + private int lines; + + private final String letters; + + private static final String alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String numbers = "0123456789"; + + private String result = ""; + + int[] lineBreaks; + + /** + * Generate alphanumeric characters. + * @param maxCharCount number of characters to generate + * @param maxLines number of lines to generate + */ + public RandomInputStream(int maxCharCount, int maxLines) { + if (maxLines > maxCharCount) { + throw new IllegalArgumentException("maxLines must be smaller than or equal to maxCharCount"); + } + + if (maxCharCount <= 0) { + throw new IllegalArgumentException("maxCharCount must be positive number"); + } + + if (maxLines <= 1) { + throw new IllegalArgumentException("maxLines must be strictly bigger than 1"); + } + + this.maxCharCount = maxCharCount; + this.charCount = 0; + this.maxLines = maxLines; + this.lines = 0; + + letters = alpha + alpha.toLowerCase() + numbers; + + // Want the newlines generally to appear within the first half of the generated data + // so that the handler has significant amount of data to read after it is done reading the lines. + lineBreaks = new int[maxLines - 1]; + for (int i = 0; i < lineBreaks.length; i++) { + lineBreaks[i] = ThreadLocalRandom.current().nextInt(1, maxCharCount / 2); + } + } + + int getCharCount() { + return charCount; + } + + String getResult() { + return result; + } + + @Override + public int read() throws IOException { + int ret; + if (charCount < maxCharCount) { + if (charCount > 0 && lines < maxLines - 1 && charCount == lineBreaks[lines]) { + ret = '\n'; + lines++; + } else { + ret = letters.charAt(ThreadLocalRandom.current().nextInt(0, letters.length())); + } + result += String.format("%c", ret); + charCount++; + return ret; + } + + return -1; + } + } + + @Test + public void testHeadHandler() throws IOException { + LOGGER.log(Level.INFO, "testing HeadHandler with: {0}/{1}/{2}", + new Object[]{lineCnt, headLineCnt, totalCharCount}); + + RandomInputStream rndStream = new RandomInputStream(totalCharCount, lineCnt); + HeadHandler handler = new HeadHandler(headLineCnt); + assertTrue(totalCharCount >= HeadHandler.getBufferedReaderSize(), + "number of characters to generate must be bigger than " + + "HeadHandler internal buffer size"); + handler.processStream(rndStream); + assertTrue(handler.count() <= headLineCnt, + "HeadHandler should not get more lines than was asked to"); + assertEquals(totalCharCount, rndStream.getCharCount(), + "HeadHandler should read all the characters from input stream"); + String[] headLines = new String[handler.count()]; + for (int i = 0; i < handler.count(); i++) { + String line = handler.get(i); + LOGGER.log(Level.INFO, "line [{0}]: {1}", new Object[]{i, line}); + headLines[i] = line; + } + assertArrayEquals(headLines, + Arrays.copyOfRange(rndStream.getResult().split("\n"), 0, handler.count()), + "the lines retrieved by HeadHandler needs to match the input"); + } +} diff --git a/opengrok-web/src/main/java/org/opengrok/web/api/v1/FileNotFoundExceptionMapper.java b/opengrok-web/src/main/java/org/opengrok/web/api/v1/FileNotFoundExceptionMapper.java new file mode 100644 index 00000000000..690f6f53d6e --- /dev/null +++ b/opengrok-web/src/main/java/org/opengrok/web/api/v1/FileNotFoundExceptionMapper.java @@ -0,0 +1,37 @@ +/* + * CDDL HEADER START + * + * The contents of this file are subject to the terms of the + * Common Development and Distribution License (the "License"). + * You may not use this file except in compliance with the License. + * + * See LICENSE.txt included in this distribution for the specific + * language governing permissions and limitations under the License. + * + * When distributing Covered Code, include this CDDL HEADER in each + * file and include the License file at LICENSE.txt. + * If applicable, add the following below this CDDL HEADER, with the + * fields enclosed by brackets "[]" replaced with your own identifying + * information: Portions Copyright [yyyy] [name of copyright owner] + * + * CDDL HEADER END + */ + +/* + * Copyright (c) 2020 Oracle and/or its affiliates. All rights reserved. + */ + +package org.opengrok.web.api.v1; + +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import java.io.FileNotFoundException; + +@Provider +public class FileNotFoundExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(FileNotFoundException e) { + return Response.status(Response.Status.NOT_FOUND).build(); + } +} diff --git a/opengrok-web/src/main/java/org/opengrok/web/api/v1/NoPathParameterExceptionMapper.java b/opengrok-web/src/main/java/org/opengrok/web/api/v1/NoPathParameterExceptionMapper.java new file mode 100644 index 00000000000..c375731f401 --- /dev/null +++ b/opengrok-web/src/main/java/org/opengrok/web/api/v1/NoPathParameterExceptionMapper.java @@ -0,0 +1,38 @@ +/* + * CDDL HEADER START + * + * The contents of this file are subject to the terms of the + * Common Development and Distribution License (the "License"). + * You may not use this file except in compliance with the License. + * + * See LICENSE.txt included in this distribution for the specific + * language governing permissions and limitations under the License. + * + * When distributing Covered Code, include this CDDL HEADER in each + * file and include the License file at LICENSE.txt. + * If applicable, add the following below this CDDL HEADER, with the + * fields enclosed by brackets "[]" replaced with your own identifying + * information: Portions Copyright [yyyy] [name of copyright owner] + * + * CDDL HEADER END + */ + +/* + * Copyright (c) 2020 Oracle and/or its affiliates. All rights reserved. + */ + +package org.opengrok.web.api.v1; + +import org.opengrok.web.util.NoPathParameterException; + +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; + +@Provider +public class NoPathParameterExceptionMapper implements ExceptionMapper { + @Override + public Response toResponse(NoPathParameterException e) { + return Response.status(Response.Status.BAD_REQUEST).build(); + } +} diff --git a/opengrok-web/src/main/java/org/opengrok/web/api/v1/controller/AnnotationController.java b/opengrok-web/src/main/java/org/opengrok/web/api/v1/controller/AnnotationController.java new file mode 100644 index 00000000000..c101751e7b0 --- /dev/null +++ b/opengrok-web/src/main/java/org/opengrok/web/api/v1/controller/AnnotationController.java @@ -0,0 +1,120 @@ +/* + * CDDL HEADER START + * + * The contents of this file are subject to the terms of the + * Common Development and Distribution License (the "License"). + * You may not use this file except in compliance with the License. + * + * See LICENSE.txt included in this distribution for the specific + * language governing permissions and limitations under the License. + * + * When distributing Covered Code, include this CDDL HEADER in each + * file and include the License file at LICENSE.txt. + * If applicable, add the following below this CDDL HEADER, with the + * fields enclosed by brackets "[]" replaced with your own identifying + * information: Portions Copyright [yyyy] [name of copyright owner] + * + * CDDL HEADER END + */ + +/* + * Copyright (c) 2020 Oracle and/or its affiliates. All rights reserved. + */ + +package org.opengrok.web.api.v1.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.opengrok.indexer.history.Annotation; +import org.opengrok.indexer.history.HistoryGuru; +import org.opengrok.web.api.v1.filter.CorsEnable; +import org.opengrok.web.api.v1.filter.PathAuthorized; +import org.opengrok.web.util.NoPathParameterException; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.opengrok.web.util.FileUtil.toFile; + +@Path(AnnotationController.PATH) +public class AnnotationController { + + public static final String PATH = "/annotation"; + + static class AnnotationDTO { + @JsonProperty + private String revision; + @JsonProperty + private String author; + @JsonProperty + private String description; + @JsonProperty + private String version; + + // for testing + AnnotationDTO() { + } + + AnnotationDTO(String revision, String author, String description, String version) { + this.revision = revision; + this.author = author; + this.description = description; + this.version = version; + } + + // for testing + public String getAuthor() { + return this.author; + } + + // for testing + public String getRevision() { + return this.revision; + } + + // for testing + public String getDescription() { + return this.description; + } + + // for testing + public String getVersion() { + return this.version; + } + } + + @GET + @CorsEnable + @PathAuthorized + @Produces(MediaType.APPLICATION_JSON) + public List getContent(@Context HttpServletRequest request, + @Context HttpServletResponse response, + @QueryParam("path") final String path, + @QueryParam("revision") final String revision) + throws IOException, NoPathParameterException { + + File file = toFile(path); + + Annotation annotation = HistoryGuru.getInstance().annotate(file, + revision == null || revision.isEmpty() ? null : revision); + + ArrayList annotationList = new ArrayList<>(); + for (int i = 1; i <= annotation.size(); i++) { + annotationList.add(new AnnotationDTO(annotation.getRevision(i), + annotation.getAuthor(i), + annotation.getDesc(annotation.getRevision(i)), + annotation.getFileVersion(annotation.getRevision(i)) + "/" + annotation.getRevisions().size())); + } + + return annotationList; + } +} diff --git a/opengrok-web/src/main/java/org/opengrok/web/api/v1/controller/FileController.java b/opengrok-web/src/main/java/org/opengrok/web/api/v1/controller/FileController.java index c667d45d46c..308bb52065f 100644 --- a/opengrok-web/src/main/java/org/opengrok/web/api/v1/controller/FileController.java +++ b/opengrok-web/src/main/java/org/opengrok/web/api/v1/controller/FileController.java @@ -30,6 +30,7 @@ import org.opengrok.indexer.search.QueryBuilder; import org.opengrok.web.api.v1.filter.CorsEnable; import org.opengrok.web.api.v1.filter.PathAuthorized; +import org.opengrok.web.util.NoPathParameterException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -47,6 +48,7 @@ import java.io.InputStream; import static org.opengrok.indexer.index.IndexDatabase.getDocument; +import static org.opengrok.web.util.FileUtil.toFile; @Path(FileController.PATH) public class FileController { @@ -55,25 +57,6 @@ public class FileController { private static final RuntimeEnvironment env = RuntimeEnvironment.getInstance(); - private static File getFile(String path, HttpServletResponse response) throws IOException { - if (path == null) { - if (response != null) { - response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing path parameter"); - } - return null; - } - - File file = new File(env.getSourceRootFile(), path); - if (!file.isFile()) { - if (response != null) { - response.sendError(HttpServletResponse.SC_NOT_FOUND, "File not found"); - } - return null; - } - - return file; - } - private StreamingOutput transfer(File file) throws FileNotFoundException { InputStream in = new FileInputStream(file); return out -> { @@ -93,9 +76,9 @@ private StreamingOutput transfer(File file) throws FileNotFoundException { @Produces(MediaType.TEXT_PLAIN) public StreamingOutput getContentPlain(@Context HttpServletRequest request, @Context HttpServletResponse response, - @QueryParam("path") final String path) throws IOException, ParseException { + @QueryParam("path") final String path) throws IOException, ParseException, NoPathParameterException { - File file = getFile(path, response); + File file = toFile(path); if (file == null) { // error already set in the response return null; @@ -123,11 +106,10 @@ public StreamingOutput getContentPlain(@Context HttpServletRequest request, @Produces(MediaType.APPLICATION_OCTET_STREAM) public StreamingOutput getContentOctets(@Context HttpServletRequest request, @Context HttpServletResponse response, - @QueryParam("path") final String path) throws IOException, ParseException { + @QueryParam("path") final String path) throws IOException, ParseException, NoPathParameterException { - File file = getFile(path, response); + File file = toFile(path); if (file == null) { - // error already set in the response return null; } @@ -152,9 +134,9 @@ public StreamingOutput getContentOctets(@Context HttpServletRequest request, @Produces(MediaType.TEXT_PLAIN) public String getGenre(@Context HttpServletRequest request, @Context HttpServletResponse response, - @QueryParam("path") final String path) throws IOException, ParseException { + @QueryParam("path") final String path) throws IOException, ParseException, NoPathParameterException { - File file = getFile(path, response); + File file = toFile(path); if (file == null) { // error already set in the response return null; diff --git a/opengrok-web/src/main/java/org/opengrok/web/api/v1/filter/LocalhostFilter.java b/opengrok-web/src/main/java/org/opengrok/web/api/v1/filter/LocalhostFilter.java index 91616a7581f..94bb0fe76db 100644 --- a/opengrok-web/src/main/java/org/opengrok/web/api/v1/filter/LocalhostFilter.java +++ b/opengrok-web/src/main/java/org/opengrok/web/api/v1/filter/LocalhostFilter.java @@ -23,6 +23,7 @@ package org.opengrok.web.api.v1.filter; import org.opengrok.indexer.logger.LoggerFactory; +import org.opengrok.web.api.v1.controller.AnnotationController; import org.opengrok.web.api.v1.controller.FileController; import org.opengrok.web.api.v1.controller.HistoryController; import org.opengrok.web.api.v1.controller.SearchController; @@ -59,7 +60,7 @@ public class LocalhostFilter implements ContainerRequestFilter { */ private static final Set allowedPaths = new HashSet<>(Arrays.asList( SearchController.PATH, SuggesterController.PATH, SuggesterController.PATH + "/config", - HistoryController.PATH, FileController.PATH)); + HistoryController.PATH, FileController.PATH, AnnotationController.PATH)); @Context private HttpServletRequest request; diff --git a/opengrok-web/src/main/java/org/opengrok/web/api/v1/filter/PathAuthorizationFilter.java b/opengrok-web/src/main/java/org/opengrok/web/api/v1/filter/PathAuthorizationFilter.java index 0721fb12b0c..bbc4e27888a 100644 --- a/opengrok-web/src/main/java/org/opengrok/web/api/v1/filter/PathAuthorizationFilter.java +++ b/opengrok-web/src/main/java/org/opengrok/web/api/v1/filter/PathAuthorizationFilter.java @@ -76,12 +76,16 @@ public void filter(final ContainerRequestContext context) { if (path == null || path.isEmpty()) { logger.log(Level.WARNING, "request does not contain \"{0}\" parameter: {1}", new Object[]{PATH_PARAM, request}); - context.abortWith(Response.status(Response.Status.BAD_REQUEST).build()); + // This should align with whatever NoPathExceptionMapper does. + // We cannot throw the exception here as it would not match the filter() signature. + context.abortWith(Response.status(Response.Status.NOT_ACCEPTABLE).build()); + return; } if (!isPathAuthorized(path, request)) { // TODO: this should probably update statistics for denied requests like in AuthorizationFilter context.abortWith(Response.status(Response.Status.FORBIDDEN).build()); + return; // for good measure } } } diff --git a/opengrok-web/src/main/java/org/opengrok/web/util/FileUtil.java b/opengrok-web/src/main/java/org/opengrok/web/util/FileUtil.java new file mode 100644 index 00000000000..bd24ef00d9e --- /dev/null +++ b/opengrok-web/src/main/java/org/opengrok/web/util/FileUtil.java @@ -0,0 +1,57 @@ +/* + * CDDL HEADER START + * + * The contents of this file are subject to the terms of the + * Common Development and Distribution License (the "License"). + * You may not use this file except in compliance with the License. + * + * See LICENSE.txt included in this distribution for the specific + * language governing permissions and limitations under the License. + * + * When distributing Covered Code, include this CDDL HEADER in each + * file and include the License file at LICENSE.txt. + * If applicable, add the following below this CDDL HEADER, with the + * fields enclosed by brackets "[]" replaced with your own identifying + * information: Portions Copyright [yyyy] [name of copyright owner] + * + * CDDL HEADER END + */ + +/* + * Copyright (c) 2020 Oracle and/or its affiliates. All rights reserved. + */ + +package org.opengrok.web.util; + +import org.opengrok.indexer.configuration.RuntimeEnvironment; + +import java.io.File; +import java.io.FileNotFoundException; + +public class FileUtil { + + private static final RuntimeEnvironment env = RuntimeEnvironment.getInstance(); + + // private to enforce static + private FileUtil() { + } + + /** + * @param path path relative to source root + * @return file object corresponding to the file under source root + * @throws FileNotFoundException if the file constructed from the {@code path} parameter and source root does not exist + * @throws NoPathParameterException if the {@code path} parameter is null + */ + public static File toFile(String path) throws NoPathParameterException, FileNotFoundException { + if (path == null) { + throw new NoPathParameterException("Missing path parameter"); + } + + File file = new File(env.getSourceRootFile(), path); + if (!file.isFile()) { + throw new FileNotFoundException("File " + file.toString() + "not found"); + } + + return file; + } +} diff --git a/opengrok-web/src/main/java/org/opengrok/web/util/NoPathParameterException.java b/opengrok-web/src/main/java/org/opengrok/web/util/NoPathParameterException.java new file mode 100644 index 00000000000..295342f5ec7 --- /dev/null +++ b/opengrok-web/src/main/java/org/opengrok/web/util/NoPathParameterException.java @@ -0,0 +1,36 @@ +/* + * CDDL HEADER START + * + * The contents of this file are subject to the terms of the + * Common Development and Distribution License (the "License"). + * You may not use this file except in compliance with the License. + * + * See LICENSE.txt included in this distribution for the specific + * language governing permissions and limitations under the License. + * + * When distributing Covered Code, include this CDDL HEADER in each + * file and include the License file at LICENSE.txt. + * If applicable, add the following below this CDDL HEADER, with the + * fields enclosed by brackets "[]" replaced with your own identifying + * information: Portions Copyright [yyyy] [name of copyright owner] + * + * CDDL HEADER END + */ + +/* + * Copyright (c) 2020 Oracle and/or its affiliates. All rights reserved. + */ + +package org.opengrok.web.util; + +public class NoPathParameterException extends Exception { + private static final long serialVersionUID = 1L; + + public NoPathParameterException() { + super(); + } + + public NoPathParameterException(String message) { + super(message); + } +} diff --git a/opengrok-web/src/test/java/org/opengrok/web/api/v1/controller/AnnotationControllerTest.java b/opengrok-web/src/test/java/org/opengrok/web/api/v1/controller/AnnotationControllerTest.java new file mode 100644 index 00000000000..9ce8c282df0 --- /dev/null +++ b/opengrok-web/src/test/java/org/opengrok/web/api/v1/controller/AnnotationControllerTest.java @@ -0,0 +1,160 @@ +/* + * CDDL HEADER START + * + * The contents of this file are subject to the terms of the + * Common Development and Distribution License (the "License"). + * You may not use this file except in compliance with the License. + * + * See LICENSE.txt included in this distribution for the specific + * language governing permissions and limitations under the License. + * + * When distributing Covered Code, include this CDDL HEADER in each + * file and include the License file at LICENSE.txt. + * If applicable, add the following below this CDDL HEADER, with the + * fields enclosed by brackets "[]" replaced with your own identifying + * information: Portions Copyright [yyyy] [name of copyright owner] + * + * CDDL HEADER END + */ + +/* + * Copyright (c) 2020 Oracle and/or its affiliates. All rights reserved. + */ + +package org.opengrok.web.api.v1.controller; + +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.test.JerseyTest; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.opengrok.indexer.condition.ConditionalRun; +import org.opengrok.indexer.condition.ConditionalRunRule; +import org.opengrok.indexer.condition.RepositoryInstalled; +import org.opengrok.indexer.configuration.RuntimeEnvironment; +import org.opengrok.indexer.history.HistoryGuru; +import org.opengrok.indexer.history.RepositoryFactory; +import org.opengrok.indexer.index.Indexer; +import org.opengrok.indexer.util.TestRepository; + +import javax.ws.rs.core.Application; +import javax.ws.rs.core.GenericType; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ConditionalRun(RepositoryInstalled.GitInstalled.class) +public class AnnotationControllerTest extends JerseyTest { + + @Rule + public ConditionalRunRule rule = new ConditionalRunRule(); + + private RuntimeEnvironment env = RuntimeEnvironment.getInstance(); + + private TestRepository repository; + + @Override + protected Application configure() { + return new ResourceConfig(AnnotationController.class); + } + + @Before + public void setUp() throws Exception { + super.setUp(); + repository = new TestRepository(); + repository.create(HistoryGuru.class.getResourceAsStream("repositories.zip")); + + env.setSourceRoot(repository.getSourceRoot()); + env.setDataRoot(repository.getDataRoot()); + env.setProjectsEnabled(true); + env.setHistoryEnabled(true); + RepositoryFactory.initializeIgnoredNames(env); + + Indexer.getInstance().prepareIndexer( + env, + true, // search for repositories + true, // scan and add projects + false, // don't create dictionary + null, // subFiles - needed when refreshing history partially + null); // repositories - needed when refreshing history partially + } + + @After + public void tearDown() throws Exception { + super.tearDown(); + + // This should match Configuration constructor. + env.setProjects(new ConcurrentHashMap<>()); + env.setRepositories(new ArrayList<>()); + env.getProjectRepositoriesMap().clear(); + + repository.destroy(); + } + + private static int getNumLines(File file) throws IOException { + int lines = 0; + + try (BufferedReader in = new BufferedReader(new FileReader(file))) { + while (in.readLine() != null) { + lines++; + } + } + + return lines; + } + + @Test + public void testAnnotationAPI() throws IOException { + final String path = "git/Makefile"; + List annotations = target("annotation") + .queryParam("path", path) + .request() + .get(new GenericType>() {}); + assertEquals(getNumLines(new File(env.getSourceRootFile(), path)), annotations.size()); + assertEquals("Trond Norbye", annotations.get(0).getAuthor()); + List ids = annotations.stream(). + map(AnnotationController.AnnotationDTO::getRevision). + collect(Collectors.toList()); + assertEquals(Arrays.asList("bb74b7e8", "bb74b7e8", "bb74b7e8", "bb74b7e8", "bb74b7e8", + "bb74b7e8", "bb74b7e8", "bb74b7e8", "aa35c258", "aa35c258", "aa35c258"), ids); + List versions = annotations.stream(). + map(AnnotationController.AnnotationDTO::getVersion). + collect(Collectors.toList()); + assertEquals(Arrays.asList("1/2", "1/2", "1/2", "1/2", "1/2", "1/2", "1/2", "1/2", "2/2", "2/2", "2/2"), + versions); + assertTrue(annotations.get(0).getDescription().contains("sunray")); + } + + @Test + public void testAnnotationAPIWithRevision() throws IOException { + final String path = "git/Makefile"; + List annotations = target("annotation") + .queryParam("path", path) + .queryParam("revision", "bb74b7e8") + .request() + .get(new GenericType>() {}); + assertEquals(8, annotations.size()); + assertEquals("Trond Norbye", annotations.get(0).getAuthor()); + Set ids = annotations.stream(). + map(AnnotationController.AnnotationDTO::getRevision). + collect(Collectors.toSet()); + List versions = annotations.stream(). + map(AnnotationController.AnnotationDTO::getVersion). + collect(Collectors.toList()); + assertEquals(Arrays.asList("1/1", "1/1", "1/1", "1/1", "1/1", "1/1", "1/1", "1/1"), + versions); + assertEquals(Collections.singleton("bb74b7e8"), ids); + } +}