/* Copyright (c) 2014 Boundless and others. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Distribution License v1.0 * which accompanies this distribution, and is available at * https://www.eclipse.org/org/documents/edl-v10.html * * Contributors: * David Winslow (Boundless) - initial implementation */ package org.locationtech.geogig.web.console; import static com.google.common.base.Preconditions.checkArgument; import java.io.BufferedOutputStream; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import jline.UnsupportedTerminal; import jline.console.ConsoleReader; import org.locationtech.geogig.api.Context; import org.locationtech.geogig.api.GeoGIG; import org.locationtech.geogig.api.Platform; import org.locationtech.geogig.api.porcelain.ConfigGet; import org.locationtech.geogig.cli.ArgumentTokenizer; import org.locationtech.geogig.cli.GeogigCLI; import org.locationtech.geogig.rest.repository.RESTUtils; import org.restlet.data.MediaType; import org.restlet.data.Request; import org.restlet.resource.InputRepresentation; import org.restlet.resource.Resource; import org.restlet.resource.StreamRepresentation; import com.google.common.base.Charsets; import com.google.common.base.Optional; import com.google.common.base.Preconditions; import com.google.common.base.Throwables; import com.google.common.io.ByteStreams; import com.google.common.io.CharSource; import com.google.common.io.FileBackedOutputStream; import com.google.gson.JsonObject; import com.google.gson.JsonParser; /** * The main entry point for the web console. * <p> * For the console to process commands, the {@code web.console.enabled} config option must be set to * {@code true}. * <p> * Commands come in as <a href="http://json-rpc.org/wiki/specification">JSON RPC 2.0</a> messages * using POST method to the {@code /console/run-command} end point. * <p> * Example request body content: * <ul> * <li> <code>{"jsonrpc":"2.0","method":"status","params":["--help"],"id":3}</code> * <li> * <code>{"jsonrpc":"2.0","method":"commit","params":["roads","-m","deleted one road"],"id":8}</code> * </ul> */ public class ConsoleResourceResource extends Resource { @Override public boolean allowGet() { return true; } @Override public boolean allowPost() { return true; } /** * Handles JSON RPC 2.0 (http://json-rpc.org/wiki/specification) calls to the * <code>/console/run-command end point</code>. */ @Override public void handlePost() { final Request request = getRequest(); final String resource = RESTUtils.getStringAttribute(getRequest(), "resource"); checkArgument("run-command".equals(resource), "Invalid entry point. Expected: run-command."); JsonParser parser = new JsonParser(); InputRepresentation entityAsObject = (InputRepresentation) request.getEntity(); JsonObject json; try { InputStream stream = entityAsObject.getStream(); InputStreamReader reader = new InputStreamReader(stream); json = (JsonObject) parser.parse(reader); } catch (Exception e) { throw Throwables.propagate(e); } Preconditions.checkArgument("2.0".equals(json.get("jsonrpc").getAsString())); Optional<GeoGIG> providedGeogig = RESTUtils.getGeogig(request); checkArgument(providedGeogig.isPresent()); final GeoGIG geogig = providedGeogig.get(); JsonObject response; if (!checkConsoleEnabled(geogig.getContext())) { response = serviceDisabled(json); } else { response = processRequest(json, geogig); } getResponse().setEntity(response.toString(), MediaType.APPLICATION_JSON); } private JsonObject processRequest(JsonObject json, final GeoGIG geogig) { JsonObject response; final String command = json.get("method").getAsString(); final String queryId = json.get("id").getAsString(); // not used, we're getting the whole command and args in the "method" object // JsonArray paramsArray = json.get("params").getAsJsonArray(); InputStream in = new ByteArrayInputStream(new byte[0]); // dumps output to a temp file if > threshold FileBackedOutputStream out = new FileBackedOutputStream(4096); try { // pass it a BufferedOutputStream 'cause it doesn't buffer the internal FileOutputStream ConsoleReader console = new ConsoleReader(in, new BufferedOutputStream(out), new UnsupportedTerminal()); Platform platform = geogig.getPlatform(); GeogigCLI geogigCLI = new GeogigCLI(geogig, console); geogigCLI.setPlatform(platform); geogigCLI.disableProgressListener(); String[] args = ArgumentTokenizer.tokenize(command); final int exitCode = geogigCLI.execute(args); response = new JsonObject(); response.addProperty("id", queryId); final int charCountLimit = getOutputLimit(geogig.getContext()); final StringBuilder output = getLimitedOutput(out, charCountLimit); if (exitCode == 0) { response.addProperty("result", output.toString()); response.addProperty("error", (String) null); } else { Exception exception = geogigCLI.exception; JsonObject error = buildError(exitCode, output, exception); response.add("error", error); } return response; } catch (IOException e) { throw Throwables.propagate(e); } finally { // delete temp file try { out.reset(); } catch (IOException ignore) { ignore.printStackTrace(); } } } private JsonObject serviceDisabled(JsonObject request) { final String queryId = request.get("id").getAsString(); JsonObject response = new JsonObject(); response.addProperty("id", queryId); JsonObject error = new JsonObject(); error.addProperty("code", "-1"); String message = "Web-console service is disabled. Run 'geogig config web.console.enabled true' on a real terminal to enable it."; error.addProperty("message", message); response.add("error", error); return response; } private boolean checkConsoleEnabled(Context ctx) { Optional<String> configOption = ctx.command(ConfigGet.class).setName("web.console.enabled") .call(); boolean enabled = configOption.isPresent() && Boolean.parseBoolean(configOption.get()); return enabled; } private int getOutputLimit(Context ctx) { final int defaultLimit = 1024 * 16; Optional<String> configuredLimit = ctx.command(ConfigGet.class) .setName("web.console.limit").call(); int limit = defaultLimit; if (configuredLimit.isPresent()) { try { limit = Integer.parseInt(configuredLimit.get()); } catch (NumberFormatException ignore) { // limit = defaultLimit; } if (limit < 1024) { limit = 1024; } } return limit; } private StringBuilder getLimitedOutput(FileBackedOutputStream out, final int limit) throws IOException { CharSource charSource = out.asByteSource().asCharSource(Charsets.UTF_8); BufferedReader reader = charSource.openBufferedStream(); final StringBuilder output = new StringBuilder(); int count = 0; String line; while ((line = reader.readLine()) != null) { output.append(line).append('\n'); count += line.length(); if (count >= limit) { output.append("\nNote: output limited to ") .append(count) .append(" characters. Run config web.console.limit <newlimit> to change the current ") .append(limit).append(" soft limit."); break; } } return output; } private JsonObject buildError(final int exitCode, final StringBuilder output, Exception exception) { JsonObject error = new JsonObject(); error.addProperty("code", Integer.valueOf(exitCode)); if (output.length() == 0 && exception != null && exception.getMessage() != null) { output.append(exception.getMessage()); } String message = output.toString(); error.addProperty("message", message); return error; } @Override public void handleGet() { final String resourceName; { String res = RESTUtils.getStringAttribute(getRequest(), "resource"); if (null == res) { resourceName = "terminal.html"; } else { resourceName = res; } } MediaType mediaType = guessMediaType(resourceName); getResponse().setEntity(new StreamRepresentation(mediaType) { @Override public void write(OutputStream outputStream) throws IOException { // System.out.println("returning " + resourceName); ByteStreams.copy(getStream(), outputStream); } @Override public InputStream getStream() throws IOException { InputStream inputStream = ConsoleResourceResource.class .getResourceAsStream(resourceName); return inputStream; } }); } private MediaType guessMediaType(final String resourceName) { final int extIdx = resourceName.lastIndexOf('.'); final String extension = resourceName.substring(extIdx + 1).toLowerCase(); if ("js".equals(extension)) { return MediaType.APPLICATION_JAVASCRIPT; } if ("css".equals(extension)) { return MediaType.TEXT_CSS; } if ("html".equals(extension)) { return MediaType.TEXT_HTML; } return MediaType.APPLICATION_OCTET_STREAM; } }