blob: 6312c38ac781a37980171021ee8f8b20e6ddc7e4 [file] [log] [blame]
// Copyright (C) 2014 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.googlesource.gerrit.plugins.xdocs;
import static java.nio.charset.StandardCharsets.UTF_8;
import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
import com.google.common.base.CharMatcher;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.hash.Hasher;
import com.google.common.hash.Hashing;
import com.google.common.net.HttpHeaders;
import com.google.gerrit.common.data.PatchScript.FileMode;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.httpd.resources.Resource;
import com.google.gerrit.httpd.resources.SmallResource;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.mime.FileTypeRegistry;
import com.google.gerrit.server.change.FileContentUtil;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.permissions.RefPermission;
import com.google.gerrit.server.project.CommitsCollection;
import com.google.gerrit.server.project.GetHead;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectControl;
import com.google.gerrit.server.project.ProjectResource;
import com.google.gerrit.server.project.ProjectState;
import com.google.gwtexpui.server.CacheHeaders;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import com.googlesource.gerrit.plugins.xdocs.formatter.Formatters;
import com.googlesource.gerrit.plugins.xdocs.formatter.Formatters.FormatterProvider;
import eu.medsea.mimeutil.MimeType;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.errors.RevisionSyntaxException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.PathFilter;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Singleton
public class XDocServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
public static final String PATH_PREFIX = "/project/";
private final String pluginName;
private final Provider<ReviewDb> db;
private final ProjectControl.Factory projectControlFactory;
private final ProjectCache projectCache;
private final Provider<GetHead> getHead;
private final GitRepositoryManager repoManager;
private final XDocCache docCache;
private final FileTypeRegistry fileTypeRegistry;
private final XDocProjectConfig.Factory cfgFactory;
private final Formatters formatters;
private final CommitsCollection commits;
private final PermissionBackend permissionBackend;
@Inject
XDocServlet(
@PluginName String pluginName,
Provider<ReviewDb> db,
ProjectControl.Factory projectControlFactory,
ProjectCache projectCache,
Provider<GetHead> getHead,
GitRepositoryManager repoManager,
XDocCache cache,
FileTypeRegistry fileTypeRegistry,
XDocProjectConfig.Factory cfgFactory,
Formatters formatters,
CommitsCollection commits,
PermissionBackend permissionBackend) {
this.pluginName = pluginName;
this.db = db;
this.projectControlFactory = projectControlFactory;
this.projectCache = projectCache;
this.getHead = getHead;
this.repoManager = repoManager;
this.docCache = cache;
this.fileTypeRegistry = fileTypeRegistry;
this.cfgFactory = cfgFactory;
this.formatters = formatters;
this.commits = commits;
this.permissionBackend = permissionBackend;
}
@Override
public void service(HttpServletRequest req, HttpServletResponse res)
throws IOException {
try {
validateRequestMethod(req);
ResourceKey key = ResourceKey.fromPath(getEncodedPath(req));
ProjectState state = getProject(key);
XDocProjectConfig cfg = cfgFactory.create(state);
if (key.file == null) {
res.sendRedirect(getRedirectUrl(req, key, cfg));
return;
}
MimeType mimeType = fileTypeRegistry.getMimeType(key.file, (byte[])null);
mimeType = new MimeType(FileContentUtil.resolveContentType(
state, key.file, FileMode.FILE, mimeType.toString()));
FormatterProvider formatter = getFormatter(req, key, mimeType);
validateDiffMode(key);
ProjectControl projectControl = projectControlFactory.controlFor(key.project);
String rev = getRevision(
key.diffMode == DiffMode.NO_DIFF
? MoreObjects.firstNonNull(key.revision, cfg.getIndexRef())
: key.revision,
projectControl);
String revB = getRevision(key.revisionB, projectControl);
try (Repository repo = repoManager.openRepository(key.project)) {
ObjectId revId =
resolveRevision(repo,
key.diffMode == DiffMode.NO_DIFF
? MoreObjects.firstNonNull(rev, Constants.HEAD)
: rev);
if (revId != null && ObjectId.isId(rev)) {
validateCanReadCommit(repo, projectControl, revId);
}
ObjectId revIdB = resolveRevision(repo, revB);
if (revIdB != null && ObjectId.isId(revB)) {
validateCanReadCommit(repo, projectControl, revIdB);
}
if (isResourceNotModified(req, key, revId, revIdB)) {
res.sendError(SC_NOT_MODIFIED);
return;
}
Resource rsc;
if (formatter != null) {
rsc = docCache.get(formatter, key.project, key.file, revId,
revIdB, key.diffMode);
} else if (isImage(mimeType)) {
rsc = getImageResource(repo, key.diffMode, revId, revIdB, key.file);
} else {
rsc = Resource.NOT_FOUND;
}
if (rsc != Resource.NOT_FOUND) {
res.setHeader(HttpHeaders.ETAG,
computeETag(key.project, revId, key.file, revIdB, key.diffMode));
}
if (key.diffMode == DiffMode.NO_DIFF && rev == null) {
// file was loaded from HEAD, since HEAD is modifiable the document
// should only be cached for a short period
CacheHeaders.setCacheablePrivate(res, 15, TimeUnit.MINUTES, false);
} else {
CacheHeaders.setCacheablePrivate(res, 7, TimeUnit.DAYS, false);
}
rsc.send(req, res);
return;
}
} catch (RepositoryNotFoundException | NoSuchProjectException
| ResourceNotFoundException | AuthException | RevisionSyntaxException e) {
Resource.NOT_FOUND.send(req, res);
} catch (MethodNotAllowedException e) {
CacheHeaders.setNotCacheable(res);
res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
} catch (PermissionBackendException e) {
//
}
}
private String getEncodedPath(HttpServletRequest req) {
String path = req.getRequestURI();
String prefix = "/plugins/" + pluginName;
if (path.startsWith(prefix)) {
path = path.substring(prefix.length());
}
return path;
}
private Resource getImageResource(Repository repo, DiffMode diffMode,
ObjectId revId, ObjectId revIdB, String file) {
ObjectId id = diffMode == DiffMode.NO_DIFF || diffMode == DiffMode.SIDEBYSIDE_A
? revId
: revIdB;
try (RevWalk rw = new RevWalk(repo)) {
RevCommit commit = rw.parseCommit(id);
RevTree tree = commit.getTree();
try (TreeWalk tw = new TreeWalk(repo)) {
tw.addTree(tree);
tw.setRecursive(true);
tw.setFilter(PathFilter.create(file));
if (!tw.next()) {
return Resource.NOT_FOUND;
}
ObjectId objectId = tw.getObjectId(0);
ObjectLoader loader = repo.open(objectId);
byte[] content = loader.getBytes(Integer.MAX_VALUE);
MimeType mimeType = fileTypeRegistry.getMimeType(file, content);
if (!isSafeImage(mimeType)) {
return Resource.NOT_FOUND;
}
return new SmallResource(content)
.setContentType(mimeType.toString())
.setCharacterEncoding(UTF_8.name())
.setLastModified(commit.getCommitTime());
}
} catch (IOException e) {
return Resource.NOT_FOUND;
}
}
private static void validateRequestMethod(HttpServletRequest req)
throws MethodNotAllowedException {
if (!("GET".equals(req.getMethod()) || "HEAD".equals(req.getMethod()))) {
throw new MethodNotAllowedException();
}
}
private static void validateDiffMode(ResourceKey key)
throws ResourceNotFoundException {
if (key.diffMode != DiffMode.NO_DIFF && (key.revisionB == null)) {
throw new ResourceNotFoundException();
}
}
private ProjectState getProject(ResourceKey key)
throws ResourceNotFoundException {
ProjectState state = projectCache.get(key.project);
if (state == null) {
throw new ResourceNotFoundException();
}
return state;
}
private FormatterProvider getFormatter(HttpServletRequest req,
ResourceKey key, MimeType mimeType) throws ResourceNotFoundException {
FormatterProvider formatter;
if (req.getParameter("raw") != null) {
formatter = formatters.getRawFormatter();
} else {
formatter = formatters.get(getProject(key), key.file);
}
if (isSafeImage(mimeType)) {
if (req.getParameter("formatImage") == null) {
// image formatting is not requested, return the plain image
formatter = null;
}
} else {
if (formatter == null) {
throw new ResourceNotFoundException();
}
}
if (formatter == null && !isSafeImage(mimeType)) {
throw new ResourceNotFoundException();
}
return formatter;
}
private boolean isSafeImage(MimeType mimeType) {
return isImage(mimeType) && fileTypeRegistry.isSafeInline(mimeType);
}
private static boolean isImage(MimeType mimeType) {
return "image".equals(mimeType.getMediaType());
}
private String getRevision(String revision,
ProjectControl projectControl) throws ResourceNotFoundException,
AuthException, IOException, PermissionBackendException {
if (revision == null) {
return null;
}
if (ObjectId.isId(revision)) {
return revision;
}
if (Constants.HEAD.equals(revision)) {
return getHead.get().apply(new ProjectResource(projectControl));
} else {
String rev = revision;
if (!rev.startsWith(Constants.R_REFS)) {
rev = Constants.R_HEADS + rev;
}
try {
permissionBackend
.user(projectControl.getUser())
.project(projectControl.getProject().getNameKey())
.ref(rev)
.check(RefPermission.READ);
} catch (AuthException e) {
// Don't leak the project's existence
throw new ResourceNotFoundException();
}
return rev;
}
}
private static ObjectId resolveRevision(Repository repo, String revision)
throws ResourceNotFoundException, IOException {
if (revision == null) {
return null;
}
ObjectId revId = repo.resolve(revision);
if (revId == null) {
throw new ResourceNotFoundException();
}
return revId;
}
private void validateCanReadCommit(Repository repo,
ProjectControl projectControl, ObjectId revId)
throws ResourceNotFoundException, IOException {
try (RevWalk rw = new RevWalk(repo)) {
RevCommit commit = rw.parseCommit(revId);
ProjectState state = projectControl.getProjectState();
if (!commits.canRead(state, repo, commit)) {
throw new ResourceNotFoundException();
}
}
}
private static boolean isResourceNotModified(HttpServletRequest req,
ResourceKey key, ObjectId revId, ObjectId revIdB) {
String receivedETag = req.getHeader(HttpHeaders.IF_NONE_MATCH);
if (receivedETag != null) {
return receivedETag.equals(computeETag(key.project, revId, key.file,
revIdB, key.diffMode));
}
return false;
}
private static String computeETag(Project.NameKey project, ObjectId revId,
String file, ObjectId revIdB, DiffMode diffMode) {
Hasher hasher = Hashing.md5().newHasher();
hasher.putUnencodedChars(project.get());
if (revId != null) {
hasher.putUnencodedChars(revId.getName());
}
hasher.putUnencodedChars(file);
if (diffMode != DiffMode.NO_DIFF) {
hasher.putUnencodedChars(revIdB.getName()).putUnencodedChars(
diffMode.name());
}
return hasher.hash().toString();
}
private String getRedirectUrl(HttpServletRequest req, ResourceKey key,
XDocProjectConfig cfg) {
StringBuilder redirectUrl = new StringBuilder();
redirectUrl.append(req.getRequestURL().substring(0,
req.getRequestURL().length() - req.getRequestURI().length()));
redirectUrl.append(req.getContextPath());
redirectUrl.append(PATH_PREFIX);
redirectUrl.append(IdString.fromDecoded(key.project.get()).encoded());
redirectUrl.append("/");
if (key.revision != null) {
redirectUrl.append("rev/");
redirectUrl.append(key.revision);
redirectUrl.append("/");
}
redirectUrl.append(cfg.getIndexFile());
return redirectUrl.toString();
}
private static class ResourceKey {
final Project.NameKey project;
final String file;
final String revision;
final String revisionB;
final DiffMode diffMode;
static ResourceKey fromPath(String path) {
String project;
String file = null;
String revision = null;
String revisionB = null;
DiffMode diffMode = DiffMode.NO_DIFF;
if (!path.startsWith(PATH_PREFIX)) {
// should not happen since this servlet is only registered to handle
// paths that start with this prefix
throw new IllegalStateException("path must start with '" + PATH_PREFIX + "'");
}
path = path.substring(PATH_PREFIX.length());
int i = path.indexOf('/');
if (i != -1 && i != path.length() - 1) {
project = IdString.fromUrl(path.substring(0, i)).get();
String rest = path.substring(i + 1);
if (rest.startsWith("rev/")) {
if (rest.length() > 4) {
rest = rest.substring(4);
i = rest.indexOf('/');
if (i != -1 && i != path.length() - 1) {
revision = IdString.fromUrl(rest.substring(0, i)).get();
file = rest.substring(i + 1);
} else {
revision = IdString.fromUrl(rest).get();
}
}
} else {
file = rest;
}
} else {
project = IdString.fromUrl(CharMatcher.is('/').trimTrailingFrom(path)).get();
}
if (revision != null) {
if (revision.contains("<->")) {
diffMode = DiffMode.UNIFIED;
int p = revision.indexOf("<->");
revisionB = revision.substring(p + 3);
revision = Strings.emptyToNull(revision.substring(0, p));
} else if (revision.contains("<-")) {
diffMode = DiffMode.SIDEBYSIDE_A;
int p = revision.indexOf("<-");
revisionB = revision.substring(p + 2);
revision = revision.substring(0, p);
} else if (revision.contains("->")) {
diffMode = DiffMode.SIDEBYSIDE_B;
int p = revision.indexOf("->");
revisionB = revision.substring(p + 2);
revision = Strings.emptyToNull(revision.substring(0, p));
}
}
return new ResourceKey(project, file, revision, revisionB, diffMode);
}
private ResourceKey(String p, String f, String r, String r2, DiffMode dm) {
project = new Project.NameKey(p);
file = f;
revision = r;
revisionB = r2;
diffMode = dm;
}
}
}