diff --git a/build.gradle b/build.gradle index dd0376ba0c..b1d8e371d5 100644 --- a/build.gradle +++ b/build.gradle @@ -103,6 +103,7 @@ configure([project(':cli'), project(':seednode'), project(':statsnode'), project(':pricenode'), + project(':daonode'), project(':inventory'), project(':apitest')]) { @@ -686,6 +687,49 @@ configure(project(':seednode')) { } } +configure(project(':daonode')) { + apply plugin: 'com.github.johnrengelman.shadow' + + mainClassName = 'bisq.daonode.DaoNodeMain' + + dependencies { + implementation project(':common') + implementation project(':proto') + implementation project(':p2p') + implementation project(':core') + implementation "com.google.guava:guava:$guavaVersion" + implementation("com.google.inject:guice:$guiceVersion") { + exclude(module: 'guava') + } + + annotationProcessor "org.projectlombok:lombok:$lombokVersion" + compileOnly "org.projectlombok:lombok:$lombokVersion" + implementation "ch.qos.logback:logback-classic:$logbackVersion" + implementation "ch.qos.logback:logback-core:$logbackVersion" + implementation "org.slf4j:slf4j-api:$slf4jVersion" + + implementation("com.fasterxml.jackson.core:jackson-core:$jacksonVersion") + implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + implementation("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion") + + implementation "com.google.protobuf:protobuf-java-util:$protobufVersion" + + testImplementation "org.mockito:mockito-core:$mockitoVersion" + + testCompileOnly "org.projectlombok:lombok:$lombokVersion" + testImplementation "org.apache.httpcomponents:httpclient:$httpclientVersion" + testImplementation "org.junit.jupiter:junit-jupiter-api:$jupiterVersion" + testImplementation "org.junit.jupiter:junit-jupiter-params:$jupiterVersion" + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$jupiterVersion") + } + + test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } + } +} configure(project(':statsnode')) { mainClassName = 'bisq.statistics.StatisticsMain' diff --git a/daonode/src/main/java/bisq/daonode/DaoNode.java b/daonode/src/main/java/bisq/daonode/DaoNode.java new file mode 100644 index 0000000000..8180e0714c --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/DaoNode.java @@ -0,0 +1,65 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.daonode; + +import bisq.core.app.misc.AppSetup; +import bisq.core.app.misc.AppSetupWithP2PAndDAO; +import bisq.core.dao.state.DaoStateService; +import bisq.core.network.p2p.inventory.GetInventoryRequestHandler; +import bisq.core.user.Preferences; + +import com.google.inject.Injector; + +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + + + +import bisq.daonode.service.DaoNodeService; + +@Slf4j +public class DaoNode { + @Setter + private Injector injector; + private AppSetup appSetup; + private DaoNodeService daoNodeService; + private GetInventoryRequestHandler getInventoryRequestHandler; + + public DaoNode() { + } + + public void startApplication(int restServerPort) { + appSetup = injector.getInstance(AppSetupWithP2PAndDAO.class); + + // todo should run as full dao node when in production + injector.getInstance(Preferences.class).setUseFullModeDaoMonitor(false); + + appSetup.start(); + + getInventoryRequestHandler = injector.getInstance(GetInventoryRequestHandler.class); + DaoStateService daoStateService = injector.getInstance(DaoStateService.class); + + daoNodeService = new DaoNodeService(daoStateService); + daoNodeService.start(restServerPort); + } + + public void shutDown() { + getInventoryRequestHandler.shutDown(); + daoNodeService.shutDown(); + } +} diff --git a/daonode/src/main/java/bisq/daonode/DaoNodeMain.java b/daonode/src/main/java/bisq/daonode/DaoNodeMain.java new file mode 100644 index 0000000000..3c37e859a0 --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/DaoNodeMain.java @@ -0,0 +1,215 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.daonode; + + +import bisq.core.app.TorSetup; +import bisq.core.app.misc.ExecutableForAppWithP2p; +import bisq.core.app.misc.ModuleForAppWithP2p; +import bisq.core.dao.state.DaoStateSnapshotService; +import bisq.core.user.Cookie; +import bisq.core.user.CookieKey; +import bisq.core.user.User; + +import bisq.network.p2p.P2PService; +import bisq.network.p2p.P2PServiceListener; +import bisq.network.p2p.peers.PeerManager; + +import bisq.common.Timer; +import bisq.common.UserThread; +import bisq.common.app.AppModule; +import bisq.common.app.DevEnv; +import bisq.common.config.BaseCurrencyNetwork; +import bisq.common.config.Config; +import bisq.common.handlers.ResultHandler; + +import com.google.inject.Key; +import com.google.inject.name.Names; + +import lombok.extern.slf4j.Slf4j; +//todo not sure if the restart handling from seed nodes is required + +@Slf4j +public class DaoNodeMain extends ExecutableForAppWithP2p { + private static final long CHECK_CONNECTION_LOSS_SEC = 30; + private static final int DEFAULT_REST_SERVER_PORT = 8080; + private static final String VERSION = "1.8.4"; + + private DaoNode daoNode; + private Timer checkConnectionLossTime; + private int restServerPort = DEFAULT_REST_SERVER_PORT; + + public DaoNodeMain() { + super("Bisq Daonode", "bisq-daonode", "bisq_daonode", VERSION); + } + + public static void main(String[] args) { + System.out.println("DaoNode.VERSION: " + VERSION); + + new DaoNodeMain().execute(args); + } + + @Override + protected void doExecute() { + super.doExecute(); + + checkMemory(config, this); + + keepRunning(); + } + + @Override + protected void addCapabilities() { + } + + @Override + protected void launchApplication() { + UserThread.execute(() -> { + try { + daoNode = new DaoNode(); + onApplicationLaunched(); + } catch (Exception e) { + e.printStackTrace(); + } + }); + } + + @Override + protected void onApplicationLaunched() { + super.onApplicationLaunched(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // We continue with a series of synchronous execution tasks + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + protected AppModule getModule() { + return new ModuleForAppWithP2p(config); + } + + @Override + protected void applyInjector() { + super.applyInjector(); + + daoNode.setInjector(injector); + + if (DevEnv.isDaoActivated()) { + injector.getInstance(DaoStateSnapshotService.class).setDaoRequiresRestartHandler(this::gracefulShutDown); + } + } + + @Override + protected void startApplication() { + super.startApplication(); + + Cookie cookie = injector.getInstance(User.class).getCookie(); + cookie.getAsOptionalBoolean(CookieKey.CLEAN_TOR_DIR_AT_RESTART).ifPresent(wasCleanTorDirSet -> { + if (wasCleanTorDirSet) { + injector.getInstance(TorSetup.class).cleanupTorFiles(() -> { + log.info("Tor directory reset"); + cookie.remove(CookieKey.CLEAN_TOR_DIR_AT_RESTART); + }, log::error); + } + }); + + //todo add program arg for port + daoNode.startApplication(restServerPort); + + injector.getInstance(P2PService.class).addP2PServiceListener(new P2PServiceListener() { + @Override + public void onDataReceived() { + // Do nothing + } + + @Override + public void onNoSeedNodeAvailable() { + // Do nothing + } + + @Override + public void onNoPeersAvailable() { + // Do nothing + } + + @Override + public void onUpdatedDataReceived() { + // Do nothing + } + + @Override + public void onTorNodeReady() { + // Do nothing + } + + @Override + public void onHiddenServicePublished() { + boolean preventPeriodicShutdownAtSeedNode = injector.getInstance(Key.get(boolean.class, + Names.named(Config.PREVENT_PERIODIC_SHUTDOWN_AT_SEED_NODE))); + if (!preventPeriodicShutdownAtSeedNode) { + startShutDownInterval(DaoNodeMain.this); + } + UserThread.runAfter(() -> setupConnectionLossCheck(), 60); + } + + @Override + public void onSetupFailed(Throwable throwable) { + // Do nothing + } + + @Override + public void onRequestCustomBridges() { + // Do nothing + } + }); + } + + private void setupConnectionLossCheck() { + // For dev testing (usually on BTC_REGTEST) we don't want to get the seed shut + // down as it is normal that the seed is the only actively running node. + if (Config.baseCurrencyNetwork() == BaseCurrencyNetwork.BTC_REGTEST) { + return; + } + + if (checkConnectionLossTime != null) { + return; + } + + checkConnectionLossTime = UserThread.runPeriodically(() -> { + if (injector.getInstance(PeerManager.class).getNumAllConnectionsLostEvents() > 1) { + // We set a flag to clear tor cache files at re-start. We cannot clear it now as Tor is used and + // that can cause problems. + injector.getInstance(User.class).getCookie().putAsBoolean(CookieKey.CLEAN_TOR_DIR_AT_RESTART, true); + shutDown(this); + } + }, CHECK_CONNECTION_LOSS_SEC); + + } + + private void gracefulShutDown() { + gracefulShutDown(() -> { + }); + } + + @Override + public void gracefulShutDown(ResultHandler resultHandler) { + daoNode.shutDown(); + super.gracefulShutDown(resultHandler); + } +} diff --git a/daonode/src/main/java/bisq/daonode/dto/ProofOfBurnDto.java b/daonode/src/main/java/bisq/daonode/dto/ProofOfBurnDto.java new file mode 100644 index 0000000000..c0a8f1bdc0 --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/dto/ProofOfBurnDto.java @@ -0,0 +1,41 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.daonode.dto; + +import lombok.Getter; + +/** + * Minimal data required for Bisq 2 reputation use case. + * Need to be in sync with the Bisq 2 ProofOfBurnDto class. + */ +@Getter +public class ProofOfBurnDto { + private String txId; + private final long burnedAmount; + private final int blockHeight; + private final long time; + private final String hash; + + public ProofOfBurnDto(String txId, long burnedAmount, int blockHeight, long time, String hash) { + this.txId = txId; + this.burnedAmount = burnedAmount; + this.blockHeight = blockHeight; + this.time = time; + this.hash = hash; + } +} diff --git a/daonode/src/main/java/bisq/daonode/service/DaoNodeService.java b/daonode/src/main/java/bisq/daonode/service/DaoNodeService.java new file mode 100644 index 0000000000..f37fbb6c7e --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/service/DaoNodeService.java @@ -0,0 +1,59 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.daonode.service; + +import bisq.core.dao.state.DaoStateService; + +import lombok.extern.slf4j.Slf4j; + + + +import bisq.daonode.web.WebServer; +import bisq.daonode.web.jdk.JdkServer; + +// Todo We should limit usage to localhost as its not intended at that stage to be used +// as a public API, but rather be used by Bisq 2 bridge clients or BSQ explorer nodes, +// both running in a localhost environment. As long that holds, we do not require a high +// level of protection against malicious usage. + +// TODO This JDK http server is a super simple implementation. We might use some other +// lightweight REST server framework. +@Slf4j +public class DaoNodeService { + private WebServer webServer; + private DaoStateService daoStateService; + + public DaoNodeService(DaoStateService daoStateService) { + this.daoStateService = daoStateService; + } + + public void start(int port) { + try { + webServer = new JdkServer(port, daoStateService); + webServer.start(); + } catch (Throwable t) { + log.error(t.toString()); + } + } + + public void shutDown() { + if (webServer != null) { + webServer.stop(0); + } + } +} diff --git a/daonode/src/main/java/bisq/daonode/web/WebServer.java b/daonode/src/main/java/bisq/daonode/web/WebServer.java new file mode 100644 index 0000000000..6173032b22 --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/web/WebServer.java @@ -0,0 +1,8 @@ +package bisq.daonode.web; + +public interface WebServer { + + void start(); + + void stop(int delay); +} diff --git a/daonode/src/main/java/bisq/daonode/web/jdk/JdkServer.java b/daonode/src/main/java/bisq/daonode/web/jdk/JdkServer.java new file mode 100644 index 0000000000..901d5d6f4d --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/web/jdk/JdkServer.java @@ -0,0 +1,132 @@ +package bisq.daonode.web.jdk; + +import bisq.core.dao.state.DaoStateService; + +import bisq.common.util.Utilities; + +import java.net.InetSocketAddress; + +import java.io.IOException; + +import java.util.concurrent.Executor; + +import lombok.extern.slf4j.Slf4j; + +import static bisq.daonode.web.jdk.handler.ResourcePathElement.DAONODE; + + + +import bisq.daonode.web.WebServer; +import bisq.daonode.web.jdk.handler.RestHandler; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +// https://dev.to/piczmar_0/framework-less-rest-api-in-java-1jbl + +// https://doc.networknt.com/getting-started/light-rest-4j +// https://github.com/piczmar/pure-java-rest-api +// https://stackoverflow.com/questions/3732109/simple-http-server-in-java-using-only-java-se-api +// https://www.programcreek.com/java-api-examples/index.php?api=com.sun.net.httpserver.HttpServer + +/** + * From https://stackoverflow.com/questions/3732109/simple-http-server-in-java-using-only-java-se-api + * + * Note that this is, in contrary to what some developers think, absolutely not forbidden + * by the well known FAQ Why Developers Should Not Write Programs That Call 'sun' Packages. + * That FAQ concerns the sun.* package (such as sun.misc.BASE64Encoder) for internal usage + * by the Oracle JRE (which would thus kill your application when you run it on a different + * JRE), not the com.sun.* package. Sun/Oracle also just develop software on top of the + * Java SE API themselves like as every other company such as Apache and so on. Moreover, + * this specific HttpServer must be present in every JDK so there is absolutely no means + * of "portability" issue like as would happen with sun.* package. Using com.sun.* classes + * is only discouraged (but not forbidden) when it concerns an implementation of a certain + * Java API, such as GlassFish (Java EE impl), Mojarra (JSF impl), Jersey (JAX-RS impl), etc. + */ +@Slf4j +public class JdkServer extends HttpServer implements WebServer { + + public static void main(String[] args) throws InterruptedException { + WebServer webServer = new JdkServer(8080, null); + webServer.start(); + Thread.sleep(40000); + webServer.stop(0); + } + + private final int port; + private final DaoStateService daoStateService; + + private HttpServer server; + + public JdkServer(int port, DaoStateService daoStateService) { + this.port = port; + this.daoStateService = daoStateService; + configure(); + } + + private void configure() { + try { + this.server = HttpServer.create(new InetSocketAddress(port), 0); + // As use case is intended for a 1 client environment we can stick with a single thread. + setExecutor(Utilities.getSingleThreadExecutor("DaoNode-API")); + // Map all request URLs starting with "/daonode" to a single RestHandler. + // The RestHandler will pass valid requests on to an appropriate handler. + createContext("/" + DAONODE, new RestHandler(daoStateService)); + } catch (IOException ex) { + log.error(ex.toString()); + throw new RuntimeException(ex); + } + } + + @Override + public void bind(InetSocketAddress addr, int backlog) throws IOException { + server.bind(addr, backlog); + } + + @Override + public void start() { + server.start(); + } + + @Override + public void setExecutor(Executor executor) { + server.setExecutor(executor); + } + + @Override + public Executor getExecutor() { + return server.getExecutor(); + } + + @Override + public HttpContext createContext(String path, HttpHandler handler) { + return server.createContext(path, handler); + } + + @Override + public HttpContext createContext(String path) { + return server.createContext(path); + } + + @Override + public void removeContext(String path) throws IllegalArgumentException { + server.removeContext(path); + } + + @Override + public void removeContext(HttpContext context) { + server.removeContext(context); + } + + @Override + public InetSocketAddress getAddress() { + return server.getAddress(); + } + + @Override + public void stop(int delay) { + if (server != null) { + server.stop(0); + } + } +} diff --git a/daonode/src/main/java/bisq/daonode/web/jdk/handler/GetProofOfBurnHandler.java b/daonode/src/main/java/bisq/daonode/web/jdk/handler/GetProofOfBurnHandler.java new file mode 100644 index 0000000000..b5c00be621 --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/web/jdk/handler/GetProofOfBurnHandler.java @@ -0,0 +1,99 @@ +package bisq.daonode.web.jdk.handler; + +import bisq.core.dao.state.DaoStateService; +import bisq.core.dao.state.model.blockchain.Tx; + +import bisq.common.util.Hex; + +import java.io.IOException; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import lombok.extern.slf4j.Slf4j; + +import static bisq.daonode.web.jdk.handler.HandlerUtil.sendResponse; +import static bisq.daonode.web.jdk.handler.HandlerUtil.toJson; +import static bisq.daonode.web.jdk.handler.HandlerUtil.wrapErrorResponse; +import static bisq.daonode.web.jdk.handler.HandlerUtil.wrapResponse; +import static bisq.daonode.web.jdk.handler.ResourcePathElement.BLOCKHEIGHT; + + + +import bisq.daonode.dto.ProofOfBurnDto; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + + +/** + * Handles daonode/proofofburn requests. Request URLs must match: + * http://localhost:/daonode/proofofburn/blockheight/ + * + * Example: http://localhost:8080/daonode/proofofburn/blockheight/731270 + */ +@Slf4j +class GetProofOfBurnHandler implements HttpHandler { + + private final DaoStateService daoStateService; + private final RequestSpec requestSpec; + + /** + * A new handler instance must be used for each request. We do not want to parse + * details from each request URI more than once; they are passed to this constructor + * from the RestHandler via the RequestSpec argument. + * + * @param daoStateService DaoStateService singleton + * @param requestSpec RESTful request details, including parsed URL parameters + */ + public GetProofOfBurnHandler(DaoStateService daoStateService, RequestSpec requestSpec) { + this.daoStateService = daoStateService; + this.requestSpec = requestSpec; + } + + @Override + public void handle(HttpExchange httpExchange) throws IOException { + try { + if (daoStateService == null) { + log.warn("DAO Node daoStateService is null; OK during web server dev/test."); + sendResponse(httpExchange, wrapResponse("[]")); + } else { + int blockHeight = requestSpec.getIntParam(BLOCKHEIGHT); + log.info("Requesting POB for blockheight {}.", blockHeight); + List data = getProofOfBurnDtoList(blockHeight); + if (data != null) { + sendResponse(httpExchange, wrapResponse(toJson(data))); + } else { + log.error("DAO Node Proof of Burn data for blockHeight {} is null.", blockHeight); + sendResponse(500, + httpExchange, + wrapErrorResponse(toJson("DAO Node proof of burn data is null."))); + } + } + } catch (RuntimeException ex) { + sendResponse(500, + httpExchange, + wrapErrorResponse(toJson(ex.getMessage()))); + } + } + + private List getProofOfBurnDtoList(int fromBlockHeight) { + return daoStateService.getProofOfBurnTxs().stream() + .filter(tx -> tx.getBlockHeight() >= fromBlockHeight) + .map(tx -> new ProofOfBurnDto(tx.getId(), + tx.getBurntBsq(), + tx.getBlockHeight(), + tx.getTime(), + getHash(tx))) + .collect(Collectors.toList()); + } + + // We strip out the version bytes + private String getHash(Tx tx) { + byte[] opReturnData = tx.getLastTxOutput().getOpReturnData(); + if (opReturnData == null) { + return ""; + } + return Hex.encode(Arrays.copyOfRange(opReturnData, 2, 22)); + } +} diff --git a/daonode/src/main/java/bisq/daonode/web/jdk/handler/HandlerUtil.java b/daonode/src/main/java/bisq/daonode/web/jdk/handler/HandlerUtil.java new file mode 100644 index 0000000000..8b60ecb987 --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/web/jdk/handler/HandlerUtil.java @@ -0,0 +1,51 @@ +package bisq.daonode.web.jdk.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.OutputStream; + +import lombok.SneakyThrows; + +import static java.lang.String.format; +import static java.nio.charset.StandardCharsets.UTF_8; + + + +import com.sun.net.httpserver.HttpExchange; + +class HandlerUtil { + + static void setDefaultResponseHeaders(HttpExchange httpExchange) { + httpExchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + } + + static void sendResponse(HttpExchange httpExchange, String response) throws IOException { + sendResponse(200, httpExchange, response); + } + + static void sendResponse(int status, HttpExchange httpExchange, String response) throws IOException { + setDefaultResponseHeaders(httpExchange); + + byte[] responseBytes = response.getBytes(UTF_8); + httpExchange.sendResponseHeaders(status, responseBytes.length); + OutputStream os = httpExchange.getResponseBody(); + os.write(responseBytes); + os.close(); + } + + // TODO make as function toWhat? + static String wrapResponse(String jsonData) { + return format("{\"data\":%s}", jsonData); + } + + // TODO make as function toErrorWhat? + static String wrapErrorResponse(String jsonError) { + return format("{\"error\":%s}", jsonError); + } + + @SneakyThrows + static String toJson(Object object) { + return new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(object); + } +} diff --git a/daonode/src/main/java/bisq/daonode/web/jdk/handler/RequestSpec.java b/daonode/src/main/java/bisq/daonode/web/jdk/handler/RequestSpec.java new file mode 100644 index 0000000000..c96ce16319 --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/web/jdk/handler/RequestSpec.java @@ -0,0 +1,117 @@ +package bisq.daonode.web.jdk.handler; + +import java.net.URI; +import java.net.URISyntaxException; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import static java.lang.String.format; +import static java.lang.System.arraycopy; + + + +import com.sun.net.httpserver.HttpExchange; + +/** + * REST request URI parser to extract parameter names and values from an HttpExchange. + * + * Splits the HttpExchange's request URI into a String[] of pathElements identifying a + * (REST) service name, a resource name, and any parameter/value pairs. + * + * This class is limited to URIs adhering a specific pattern: + * pathElements[0] = service-name, e.g., daonode + * pathElements[1] = resource-name, e.g., proofofburn + * pathElements[2, 3...N, N+1] = param-name/value pairs. + * + * For example, request URL http://localhost:8080/daonode/proofofburn/blockheight/731270 + * identifies service-name "daonode", resource-name "proofofburn", and one parameter + * "blockheight" with value 731270. + */ +@Getter +@Slf4j +class RequestSpec { + + private final HttpExchange httpExchange; + private final String method; + private final URI requestURI; + private final String[] pathElements; + private final String serviceName; + private final String resourceName; + private final Map parametersByName; + + public RequestSpec(HttpExchange httpExchange) { + this.httpExchange = httpExchange; + this.method = httpExchange.getRequestMethod(); + this.requestURI = httpExchange.getRequestURI(); + this.pathElements = toPathElements.apply(requestURI); + this.serviceName = pathElements[0]; + this.resourceName = pathElements[1]; + try { + this.parametersByName = getParametersByName(); + } catch (URISyntaxException ex) { + // OK to throw ex in this constructor? + log.error(ex.toString()); + throw new IllegalArgumentException(ex.toString()); + } + } + + public boolean isRequestingResource(String resourceName) { + return this.resourceName.equalsIgnoreCase(resourceName); + } + + public String getStringParam(String paramName) { + if (parametersByName.containsKey(paramName)) + return parametersByName.get(paramName); + else + throw new IllegalArgumentException(format("Parameter '%s' not found.", paramName)); + } + + public int getIntParam(String paramName) { + if (parametersByName.containsKey(paramName)) { + var value = parametersByName.get(paramName); + try { + return Integer.parseInt(value); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException( + format("Parameter '%s' value '%s' is not a number.", + paramName, + value)); + } + } else { + throw new IllegalArgumentException(format("Parameter '%s' not found.", paramName)); + } + } + + private final Function toPathElements = (uri) -> { + String[] raw = uri.getPath().split("/"); + String[] elements = new String[raw.length - 1]; + arraycopy(raw, 1, elements, 0, elements.length); + return elements; + }; + + private Map getParametersByName() throws URISyntaxException { + Map params = new HashMap<>(); + if (pathElements.length == 2) + return params; // There are no parameter name/value pairs in url. + + // All pathElements beyond index 1 should be param-name/value pairs, and + // a param-value must follow each param-name. + Predicate paramValueExists = (i) -> (i + 1) < pathElements.length; + for (int i = 2; i < pathElements.length; i++) { + String name = pathElements[i]; + if (paramValueExists.test(i)) + params.put(name, pathElements[++i]); + else + throw new URISyntaxException(requestURI.getPath(), + format("No value found for parameter with name '%s'.", name), + -1); + } + return params; + } +} diff --git a/daonode/src/main/java/bisq/daonode/web/jdk/handler/ResourcePathElement.java b/daonode/src/main/java/bisq/daonode/web/jdk/handler/ResourcePathElement.java new file mode 100644 index 0000000000..8413acd071 --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/web/jdk/handler/ResourcePathElement.java @@ -0,0 +1,7 @@ +package bisq.daonode.web.jdk.handler; + +public class ResourcePathElement { + public static String DAONODE = "daonode"; + public static final String BLOCKHEIGHT = "blockheight"; + public static final String PROOFOFBURN = "proofofburn"; +} diff --git a/daonode/src/main/java/bisq/daonode/web/jdk/handler/RestHandler.java b/daonode/src/main/java/bisq/daonode/web/jdk/handler/RestHandler.java new file mode 100644 index 0000000000..c11701bc39 --- /dev/null +++ b/daonode/src/main/java/bisq/daonode/web/jdk/handler/RestHandler.java @@ -0,0 +1,61 @@ +package bisq.daonode.web.jdk.handler; + +import bisq.core.dao.state.DaoStateService; + +import java.io.IOException; + +import lombok.extern.slf4j.Slf4j; + +import static bisq.daonode.web.jdk.handler.HandlerUtil.sendResponse; +import static bisq.daonode.web.jdk.handler.HandlerUtil.toJson; +import static bisq.daonode.web.jdk.handler.HandlerUtil.wrapErrorResponse; +import static bisq.daonode.web.jdk.handler.ResourcePathElement.PROOFOFBURN; + + + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; + +/** + * All HTTP requests are routed to a singleton RestHandler, then passed on to + * appropriate sub handler instances. + */ +@Slf4j +public class RestHandler implements HttpHandler { + + private final DaoStateService daoStateService; + + public RestHandler(DaoStateService daoStateService) { + this.daoStateService = daoStateService; + } + + @Override + public void handle(HttpExchange httpExchange) throws IOException { + try { + if (httpExchange == null) + throw new NullPointerException("HttpExchange cannot be null."); + + // Parse the request URI details here, and pass them to a new + // sub handler instance. + RequestSpec requestSpec = new RequestSpec(httpExchange); + + // Atm we only use GET, no plans yet for allow POST for DaoNode API. + if (!requestSpec.getMethod().equals("GET")) { + sendResponse(405, + httpExchange, + wrapErrorResponse(toJson("Forbidden HTTP method " + requestSpec.getMethod()))); + } else if (requestSpec.isRequestingResource(PROOFOFBURN)) { + new GetProofOfBurnHandler(daoStateService, requestSpec).handle(httpExchange); + } else { + sendResponse(404, httpExchange, wrapErrorResponse(toJson("Not Found"))); + } + + } catch (RuntimeException ex) { + sendResponse(500, + httpExchange, + wrapErrorResponse(toJson(ex.getMessage()))); + } finally { + httpExchange.close(); + } + } +} diff --git a/daonode/src/main/resources/logback.xml b/daonode/src/main/resources/logback.xml new file mode 100644 index 0000000000..914b9d3d4b --- /dev/null +++ b/daonode/src/main/resources/logback.xml @@ -0,0 +1,15 @@ + + + + + %highlight(%d{MMM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{15}: %msg %xEx%n) + + + + + + + + + + diff --git a/daonode/src/test/resources/logback.xml b/daonode/src/test/resources/logback.xml new file mode 100644 index 0000000000..28279faa11 --- /dev/null +++ b/daonode/src/test/resources/logback.xml @@ -0,0 +1,20 @@ + + + + + + %highlight(%d{MMM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{30}: %msg %xEx%n) + + + + + + + + + diff --git a/gradle.properties b/gradle.properties index 38fb1df208..e41d50899a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,3 +3,6 @@ systemProp.org.gradle.internal.http.socketTimeout=120000 # Makes Y/N user prompts more readable during installer packaging org.gradle.console=plain + +# org.gradle.dependency.verification.console=verbose +org.gradle.dependency.verification=lenient diff --git a/settings.gradle b/settings.gradle index 1827ef9c4f..2c3dbef662 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,6 +11,7 @@ include 'pricenode' include 'relay' include 'seednode' include 'statsnode' +include 'daonode' include 'inventory' include 'apitest'