diff --git a/src/main/java/org/qortal/api/ApiError.java b/src/main/java/org/qortal/api/ApiError.java index 14ab1f4f..f39ff7a0 100644 --- a/src/main/java/org/qortal/api/ApiError.java +++ b/src/main/java/org/qortal/api/ApiError.java @@ -5,6 +5,12 @@ import static java.util.stream.Collectors.toMap; import java.util.Map; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; + +@XmlAccessorType(XmlAccessType.NONE) +@XmlRootElement public enum ApiError { // COMMON // UNKNOWN(0, 500), diff --git a/src/main/java/org/qortal/api/ApiErrorRoot.java b/src/main/java/org/qortal/api/ApiErrorRoot.java new file mode 100644 index 00000000..b531e023 --- /dev/null +++ b/src/main/java/org/qortal/api/ApiErrorRoot.java @@ -0,0 +1,20 @@ +package org.qortal.api; + +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +public class ApiErrorRoot { + + private ApiError apiError; + + @XmlJavaTypeAdapter(ApiErrorTypeAdapter.class) + @XmlElement(name = "error") + public ApiError getApiError() { + return this.apiError; + } + + public void setApiError(ApiError apiError) { + this.apiError = apiError; + } + +} diff --git a/src/main/java/org/qortal/api/ApiErrorTypeAdapter.java b/src/main/java/org/qortal/api/ApiErrorTypeAdapter.java new file mode 100644 index 00000000..2447f03c --- /dev/null +++ b/src/main/java/org/qortal/api/ApiErrorTypeAdapter.java @@ -0,0 +1,32 @@ +package org.qortal.api; + +import javax.xml.bind.annotation.adapters.XmlAdapter; + +public class ApiErrorTypeAdapter extends XmlAdapter { + + public static class AdaptedApiError { + public int code; + public String description; + } + + @Override + public ApiError unmarshal(AdaptedApiError adaptedInput) throws Exception { + if (adaptedInput == null) + return null; + + return ApiError.fromCode(adaptedInput.code); + } + + @Override + public AdaptedApiError marshal(ApiError output) throws Exception { + if (output == null) + return null; + + AdaptedApiError adaptedOutput = new AdaptedApiError(); + adaptedOutput.code = output.getCode(); + adaptedOutput.description = output.name(); + + return adaptedOutput; + } + +} diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index cfe4f575..c8d1d27d 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -23,6 +23,7 @@ import org.glassfish.jersey.servlet.ServletContainer; import org.qortal.api.resource.AnnotationPostProcessor; import org.qortal.api.resource.ApiDefinition; import org.qortal.api.websocket.ActiveChatsWebSocket; +import org.qortal.api.websocket.BlocksWebSocket; import org.qortal.api.websocket.ChatMessagesWebSocket; import org.qortal.settings.Settings; @@ -125,6 +126,7 @@ public class ApiService { rewriteHandler.addRule(new RedirectPatternRule("/api-documentation", "/api-documentation/")); // redirect to add trailing slash if missing } + context.addServlet(BlocksWebSocket.class, "/websockets/blocks"); context.addServlet(ActiveChatsWebSocket.class, "/websockets/chat/active/*"); context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages"); diff --git a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java index 9780fb70..9209c5b9 100644 --- a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java @@ -1,6 +1,7 @@ package org.qortal.api.websocket; import java.io.IOException; +import java.io.StringWriter; import java.io.Writer; import java.util.Collection; import java.util.Map; @@ -14,6 +15,8 @@ import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; import org.eclipse.persistence.jaxb.JAXBContextFactory; import org.eclipse.persistence.jaxb.MarshallerProperties; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrorRoot; interface ApiWebSocket { @@ -27,6 +30,19 @@ interface ApiWebSocket { return uriTemplatePathSpec.getPathParams(this.getPathInfo(session)); } + default void sendError(Session session, ApiError apiError) { + ApiErrorRoot apiErrorRoot = new ApiErrorRoot(); + apiErrorRoot.setApiError(apiError); + + StringWriter stringWriter = new StringWriter(); + try { + marshall(stringWriter, apiErrorRoot); + session.getRemote().sendString(stringWriter.toString()); + } catch (IOException e) { + // Remote end probably closed + } + } + default void marshall(Writer writer, Object object) throws IOException { Marshaller marshaller = createMarshaller(object.getClass()); diff --git a/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java b/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java new file mode 100644 index 00000000..398cdd33 --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java @@ -0,0 +1,109 @@ +package org.qortal.api.websocket; + +import java.io.IOException; +import java.io.StringWriter; + +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.servlet.WebSocketServlet; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; +import org.qortal.api.ApiError; +import org.qortal.controller.BlockNotifier; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.Base58; + +@WebSocket +@SuppressWarnings("serial") +public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket { + + @Override + public void configure(WebSocketServletFactory factory) { + factory.register(BlocksWebSocket.class); + } + + @OnWebSocketConnect + public void onWebSocketConnect(Session session) { + BlockNotifier.Listener listener = blockData -> onNotify(session, blockData); + BlockNotifier.getInstance().register(session, listener); + } + + @OnWebSocketClose + public void onWebSocketClose(Session session, int statusCode, String reason) { + BlockNotifier.getInstance().deregister(session); + } + + @OnWebSocketMessage + public void onWebSocketMessage(Session session, String message) { + // We're expecting either a base58 block signature or an integer block height + if (message.length() > 128) { + // Try base58 block signature + byte[] signature; + + try { + signature = Base58.decode(message); + } catch (NumberFormatException e) { + sendError(session, ApiError.INVALID_SIGNATURE); + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData == null) { + sendError(session, ApiError.BLOCK_UNKNOWN); + return; + } + + onNotify(session, blockData); + } catch (DataException e) { + sendError(session, ApiError.REPOSITORY_ISSUE); + } + + return; + } + + if (message.length() > 10) + // Bigger than max integer value, so probably a ping - silently ignore + return; + + // Try integer + int height; + + try { + height = Integer.parseInt(message); + } catch (NumberFormatException e) { + sendError(session, ApiError.INVALID_HEIGHT); + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + BlockData blockData = repository.getBlockRepository().fromHeight(height); + if (blockData == null) { + sendError(session, ApiError.BLOCK_UNKNOWN); + return; + } + + onNotify(session, blockData); + } catch (DataException e) { + sendError(session, ApiError.REPOSITORY_ISSUE); + } + } + + private void onNotify(Session session, BlockData blockData) { + StringWriter stringWriter = new StringWriter(); + + try { + this.marshall(stringWriter, blockData); + + session.getRemote().sendString(stringWriter.toString()); + } catch (IOException e) { + // No output this time + } + } + +} diff --git a/src/main/java/org/qortal/block/BlockMinter.java b/src/main/java/org/qortal/block/BlockMinter.java index bffee187..3542be5e 100644 --- a/src/main/java/org/qortal/block/BlockMinter.java +++ b/src/main/java/org/qortal/block/BlockMinter.java @@ -200,6 +200,7 @@ public class BlockMinter extends Thread { } boolean newBlockMinted = false; + Block newBlock = null; try { // Clear repository session state so we have latest view of data @@ -235,7 +236,6 @@ public class BlockMinter extends Thread { final int parentHeight = previousBlock.getBlockData().getHeight(); final byte[] parentBlockSignature = previousBlock.getSignature(); - Block newBlock = null; BigInteger bestWeight = null; for (int bi = 0; bi < goodBlocks.size(); ++bi) { @@ -306,7 +306,7 @@ public class BlockMinter extends Thread { } if (newBlockMinted) - Controller.getInstance().onBlockMinted(); + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } } catch (DataException e) { LOGGER.warn("Repository issue while running block minter", e); diff --git a/src/main/java/org/qortal/controller/BlockNotifier.java b/src/main/java/org/qortal/controller/BlockNotifier.java new file mode 100644 index 00000000..d4278d05 --- /dev/null +++ b/src/main/java/org/qortal/controller/BlockNotifier.java @@ -0,0 +1,43 @@ +package org.qortal.controller; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jetty.websocket.api.Session; +import org.qortal.data.block.BlockData; + +public class BlockNotifier { + + private static BlockNotifier instance; + + @FunctionalInterface + public interface Listener { + void notify(BlockData blockData); + } + + private Map listenersBySession = new HashMap<>(); + + private BlockNotifier() { + } + + public static synchronized BlockNotifier getInstance() { + if (instance == null) + instance = new BlockNotifier(); + + return instance; + } + + public synchronized void register(Session session, Listener listener) { + this.listenersBySession.put(session, listener); + } + + public synchronized void deregister(Session session) { + this.listenersBySession.remove(session); + } + + public synchronized void onNewBlock(BlockData blockData) { + for (Listener listener : this.listenersBySession.values()) + listener.notify(blockData); + } + +} diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 0606d98a..ea410c86 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -128,6 +128,7 @@ public class Controller extends Thread { private ExecutorService newTransactionExecutor = Executors.newSingleThreadExecutor(); private volatile BlockData chainTip = null; + private ExecutorService newBlockExecutor = Executors.newSingleThreadExecutor(); private long repositoryBackupTimestamp = startTime; // ms private long ntpCheckTimestamp = startTime; // ms @@ -237,7 +238,7 @@ public class Controller extends Thread { return this.chainTip; } - /** Cache new blockchain tip, and also wipe cache of online accounts. */ + /** Cache new blockchain tip. */ public void setChainTip(BlockData blockData) { this.chainTip = blockData; } @@ -601,18 +602,17 @@ public class Controller extends Thread { try (final Repository repository = RepositoryManager.getRepository()) { newChainTip = repository.getBlockRepository().getLastBlock(); - this.setChainTip(newChainTip); } catch (DataException e) { LOGGER.warn(String.format("Repository issue when trying to fetch post-synchronization chain tip: %s", e.getMessage())); return syncResult; } if (!Arrays.equals(newChainTip.getSignature(), priorChainTip.getSignature())) { - // Broadcast our new chain tip - Network.getInstance().broadcast(recipientPeer -> Network.getInstance().buildHeightMessage(recipientPeer, newChainTip)); - // Reset our cache of inferior chains inferiorChainSignatures.clear(); + + // Update chain-tip, notify peers, websockets, etc. + this.onNewBlock(newChainTip); } return syncResult; @@ -754,22 +754,17 @@ public class Controller extends Thread { requestSysTrayUpdate = true; } - public void onBlockMinted() { - // Broadcast our new height info - BlockData latestBlockData; - - try (final Repository repository = RepositoryManager.getRepository()) { - latestBlockData = repository.getBlockRepository().getLastBlock(); - this.setChainTip(latestBlockData); - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue when trying to fetch post-mint chain tip: %s", e.getMessage())); - return; - } - - Network network = Network.getInstance(); - network.broadcast(peer -> network.buildHeightMessage(peer, latestBlockData)); - + public void onNewBlock(BlockData latestBlockData) { + this.setChainTip(latestBlockData); requestSysTrayUpdate = true; + + // Broadcast our new height info and notify websocket listeners + this.newBlockExecutor.execute(() -> { + Network network = Network.getInstance(); + network.broadcast(peer -> network.buildHeightMessage(peer, latestBlockData)); + + BlockNotifier.getInstance().onNewBlock(latestBlockData); + }); } /** Callback for when we've received a new transaction via API or peer. */