From d3029a8467ddee25ba274118905fabeb0b78e97c Mon Sep 17 00:00:00 2001 From: lucko Date: Mon, 7 Feb 2022 19:13:09 +0000 Subject: [PATCH] Web editor socket connection (#3303) --- bukkit/src/main/resources/luckperms.commodore | 3 + .../common/command/CommandManager.java | 2 + .../command/access/CommandPermission.java | 1 + .../common/command/spec/CommandSpec.java | 8 +- .../commands/generic/other/HolderEditor.java | 4 +- .../commands/misc/ApplyEditsCommand.java | 2 +- .../common/commands/misc/EditorCommand.java | 4 +- .../commands/misc/TrustEditorCommand.java | 69 +++++ .../common/commands/track/TrackEditor.java | 4 +- .../luckperms/common/config/ConfigKeys.java | 7 +- .../common/event/EventDispatcher.java | 12 +- .../luckperms/common/http/BytebinClient.java | 14 +- .../common/http/BytesocksClient.java | 101 +++++++ .../luckperms/common/locale/Message.java | 91 ++++++ .../common/model/PermissionHolder.java | 29 +- .../common/model/nodemap/MutateResult.java | 152 ----------- .../common/model/nodemap/NodeMap.java | 27 +- .../common/model/nodemap/NodeMapMutable.java | 76 ++++-- .../common/model/nodemap/RecordedNodeMap.java | 41 +-- .../plugin/AbstractLuckPermsPlugin.java | 18 +- .../common/plugin/LuckPermsPlugin.java | 16 +- .../implementation/sql/SqlStorage.java | 10 +- .../luckperms/common/util/Difference.java | 208 ++++++++++++++ .../common/webeditor/WebEditorRequest.java | 143 +++++----- .../common/webeditor/WebEditorResponse.java | 139 ++++++---- .../common/webeditor/WebEditorSession.java | 206 ++++++++++++++ .../webeditor/socket/CryptographyUtils.java | 118 ++++++++ .../webeditor/socket/SocketMessageType.java | 78 ++++++ .../webeditor/socket/WebEditorSocket.java | 258 ++++++++++++++++++ .../listener/Handler.java} | 23 +- .../socket/listener/HandlerChangeRequest.java | 105 +++++++ .../socket/listener/HandlerConnected.java | 50 ++++ .../socket/listener/HandlerHello.java | 130 +++++++++ .../socket/listener/HandlerPing.java | 60 ++++ .../listener/WebEditorSocketListener.java | 181 ++++++++++++ .../common/webeditor/store/RemoteSession.java | 51 ++++ .../webeditor/store/WebEditorKeystore.java | 196 +++++++++++++ .../WebEditorSessionMap.java} | 34 +-- .../webeditor/store/WebEditorSocketMap.java | 50 ++++ .../webeditor/store/WebEditorStore.java | 57 ++++ .../main/resources/luckperms_en.properties | 12 + .../listeners/FabricCommandListUpdater.java | 6 +- .../fabric/mixin/CommandManagerMixin.java | 1 + 43 files changed, 2380 insertions(+), 417 deletions(-) create mode 100644 common/src/main/java/me/lucko/luckperms/common/commands/misc/TrustEditorCommand.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/http/BytesocksClient.java delete mode 100644 common/src/main/java/me/lucko/luckperms/common/model/nodemap/MutateResult.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/util/Difference.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorSession.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/socket/CryptographyUtils.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/socket/SocketMessageType.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/socket/WebEditorSocket.java rename common/src/main/java/me/lucko/luckperms/common/webeditor/{SessionState.java => socket/listener/Handler.java} (75%) create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerChangeRequest.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerConnected.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerHello.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerPing.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/WebEditorSocketListener.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/store/RemoteSession.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorKeystore.java rename common/src/main/java/me/lucko/luckperms/common/webeditor/{WebEditorSessionStore.java => store/WebEditorSessionMap.java} (64%) create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorSocketMap.java create mode 100644 common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorStore.java diff --git a/bukkit/src/main/resources/luckperms.commodore b/bukkit/src/main/resources/luckperms.commodore index 5d043c1ca..9d9455bba 100644 --- a/bukkit/src/main/resources/luckperms.commodore +++ b/bukkit/src/main/resources/luckperms.commodore @@ -57,6 +57,9 @@ luckperms { applyedits { code brigadier:string single_word; } + trusteditor { + id brigadier:string single_word; + } creategroup { name brigadier:string single_word { weight brigadier:integer { diff --git a/common/src/main/java/me/lucko/luckperms/common/command/CommandManager.java b/common/src/main/java/me/lucko/luckperms/common/command/CommandManager.java index e9d206227..3443eff42 100644 --- a/common/src/main/java/me/lucko/luckperms/common/command/CommandManager.java +++ b/common/src/main/java/me/lucko/luckperms/common/command/CommandManager.java @@ -51,6 +51,7 @@ import me.lucko.luckperms.common.commands.misc.SearchCommand; import me.lucko.luckperms.common.commands.misc.SyncCommand; import me.lucko.luckperms.common.commands.misc.TranslationsCommand; import me.lucko.luckperms.common.commands.misc.TreeCommand; +import me.lucko.luckperms.common.commands.misc.TrustEditorCommand; import me.lucko.luckperms.common.commands.misc.VerboseCommand; import me.lucko.luckperms.common.commands.track.CreateTrack; import me.lucko.luckperms.common.commands.track.DeleteTrack; @@ -123,6 +124,7 @@ public class CommandManager { .add(new BulkUpdateCommand()) .add(new TranslationsCommand()) .add(new ApplyEditsCommand()) + .add(new TrustEditorCommand()) .add(new CreateGroup()) .add(new DeleteGroup()) .add(new ListGroups()) diff --git a/common/src/main/java/me/lucko/luckperms/common/command/access/CommandPermission.java b/common/src/main/java/me/lucko/luckperms/common/command/access/CommandPermission.java index 606c4b790..dfb5efc3a 100644 --- a/common/src/main/java/me/lucko/luckperms/common/command/access/CommandPermission.java +++ b/common/src/main/java/me/lucko/luckperms/common/command/access/CommandPermission.java @@ -44,6 +44,7 @@ public enum CommandPermission { RELOAD_CONFIG("reloadconfig", Type.NONE), BULK_UPDATE("bulkupdate", Type.NONE), APPLY_EDITS("applyedits", Type.NONE), + TRUST_EDITOR("trusteditor", Type.NONE), TRANSLATIONS("translations", Type.NONE), CREATE_GROUP("creategroup", Type.NONE), diff --git a/common/src/main/java/me/lucko/luckperms/common/command/spec/CommandSpec.java b/common/src/main/java/me/lucko/luckperms/common/command/spec/CommandSpec.java index c162d1b44..f749a3117 100644 --- a/common/src/main/java/me/lucko/luckperms/common/command/spec/CommandSpec.java +++ b/common/src/main/java/me/lucko/luckperms/common/command/spec/CommandSpec.java @@ -86,9 +86,11 @@ public enum CommandSpec { TRANSLATIONS("/%s translations", arg("install", false) ), - APPLY_EDITS("/%s applyedits [target]", - arg("code", true), - arg("target", false) + APPLY_EDITS("/%s applyedits ", + arg("code", true) + ), + TRUST_EDITOR("/%s trusteditor ", + arg("id", true) ), CREATE_GROUP("/%s creategroup ", diff --git a/common/src/main/java/me/lucko/luckperms/common/commands/generic/other/HolderEditor.java b/common/src/main/java/me/lucko/luckperms/common/commands/generic/other/HolderEditor.java index 0dfaa0da2..d7bcaf5bd 100644 --- a/common/src/main/java/me/lucko/luckperms/common/commands/generic/other/HolderEditor.java +++ b/common/src/main/java/me/lucko/luckperms/common/commands/generic/other/HolderEditor.java @@ -42,6 +42,7 @@ import me.lucko.luckperms.common.plugin.LuckPermsPlugin; import me.lucko.luckperms.common.sender.Sender; import me.lucko.luckperms.common.util.Predicates; import me.lucko.luckperms.common.webeditor.WebEditorRequest; +import me.lucko.luckperms.common.webeditor.WebEditorSession; import net.luckperms.api.node.Node; @@ -84,8 +85,7 @@ public class HolderEditor extends ChildCommand { Message.EDITOR_START.send(sender); - WebEditorRequest.generate(holders, Collections.emptyList(), sender, label, plugin) - .createSession(plugin, sender); + WebEditorSession.createAndOpen(holders, Collections.emptyList(), sender, label, plugin); } } diff --git a/common/src/main/java/me/lucko/luckperms/common/commands/misc/ApplyEditsCommand.java b/common/src/main/java/me/lucko/luckperms/common/commands/misc/ApplyEditsCommand.java index 90c584d03..f5ca93e40 100644 --- a/common/src/main/java/me/lucko/luckperms/common/commands/misc/ApplyEditsCommand.java +++ b/common/src/main/java/me/lucko/luckperms/common/commands/misc/ApplyEditsCommand.java @@ -73,7 +73,7 @@ public class ApplyEditsCommand extends SingleCommand { return; } - new WebEditorResponse(code, data).apply(plugin, sender, label, ignoreSessionWarning); + new WebEditorResponse(code, data).apply(plugin, sender, null, label, ignoreSessionWarning); } @Override diff --git a/common/src/main/java/me/lucko/luckperms/common/commands/misc/EditorCommand.java b/common/src/main/java/me/lucko/luckperms/common/commands/misc/EditorCommand.java index d96eba560..46ac1698d 100644 --- a/common/src/main/java/me/lucko/luckperms/common/commands/misc/EditorCommand.java +++ b/common/src/main/java/me/lucko/luckperms/common/commands/misc/EditorCommand.java @@ -40,6 +40,7 @@ import me.lucko.luckperms.common.plugin.LuckPermsPlugin; import me.lucko.luckperms.common.sender.Sender; import me.lucko.luckperms.common.util.Predicates; import me.lucko.luckperms.common.webeditor.WebEditorRequest; +import me.lucko.luckperms.common.webeditor.WebEditorSession; import net.luckperms.api.node.Node; @@ -107,8 +108,7 @@ public class EditorCommand extends SingleCommand { Message.EDITOR_START.send(sender); - WebEditorRequest.generate(holders, tracks, sender, label, plugin) - .createSession(plugin, sender); + WebEditorSession.createAndOpen(holders, tracks, sender, label, plugin); } private enum Type { diff --git a/common/src/main/java/me/lucko/luckperms/common/commands/misc/TrustEditorCommand.java b/common/src/main/java/me/lucko/luckperms/common/commands/misc/TrustEditorCommand.java new file mode 100644 index 000000000..417a0d589 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/commands/misc/TrustEditorCommand.java @@ -0,0 +1,69 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.commands.misc; + +import me.lucko.luckperms.common.command.abstraction.SingleCommand; +import me.lucko.luckperms.common.command.access.CommandPermission; +import me.lucko.luckperms.common.command.spec.CommandSpec; +import me.lucko.luckperms.common.command.utils.ArgumentList; +import me.lucko.luckperms.common.locale.Message; +import me.lucko.luckperms.common.plugin.LuckPermsPlugin; +import me.lucko.luckperms.common.sender.Sender; +import me.lucko.luckperms.common.util.Predicates; +import me.lucko.luckperms.common.webeditor.socket.WebEditorSocket; + +public class TrustEditorCommand extends SingleCommand { + public TrustEditorCommand() { + super(CommandSpec.TRUST_EDITOR, "TrustEditor", CommandPermission.TRUST_EDITOR, Predicates.not(1)); + } + + @Override + public void execute(LuckPermsPlugin plugin, Sender sender, ArgumentList args, String label) { + String id = args.get(0); + + if (id.isEmpty()) { + Message.APPLY_EDITS_INVALID_CODE.send(sender, id); + return; + } + + WebEditorSocket socket = plugin.getWebEditorStore().sockets().getSocket(sender); + if (socket == null) { + Message.EDITOR_SOCKET_TRUST_FAILURE.send(sender); + return; + } + + if (socket.trustConnection(id)) { + Message.EDITOR_SOCKET_TRUST_SUCCESS.send(sender); + } else { + Message.EDITOR_SOCKET_TRUST_FAILURE.send(sender); + } + } + + @Override + public boolean shouldDisplay() { + return false; + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/commands/track/TrackEditor.java b/common/src/main/java/me/lucko/luckperms/common/commands/track/TrackEditor.java index 50af93903..215531e62 100644 --- a/common/src/main/java/me/lucko/luckperms/common/commands/track/TrackEditor.java +++ b/common/src/main/java/me/lucko/luckperms/common/commands/track/TrackEditor.java @@ -42,6 +42,7 @@ import me.lucko.luckperms.common.plugin.LuckPermsPlugin; import me.lucko.luckperms.common.sender.Sender; import me.lucko.luckperms.common.util.Predicates; import me.lucko.luckperms.common.webeditor.WebEditorRequest; +import me.lucko.luckperms.common.webeditor.WebEditorSession; import net.luckperms.api.node.Node; @@ -97,8 +98,7 @@ public class TrackEditor extends ChildCommand { Message.EDITOR_START.send(sender); - WebEditorRequest.generate(holders, Collections.singletonList(target), sender, label, plugin) - .createSession(plugin, sender); + WebEditorSession.createAndOpen(holders, Collections.singletonList(target), sender, label, plugin); } } diff --git a/common/src/main/java/me/lucko/luckperms/common/config/ConfigKeys.java b/common/src/main/java/me/lucko/luckperms/common/config/ConfigKeys.java index 970ca9760..38a228a41 100644 --- a/common/src/main/java/me/lucko/luckperms/common/config/ConfigKeys.java +++ b/common/src/main/java/me/lucko/luckperms/common/config/ConfigKeys.java @@ -692,7 +692,12 @@ public final class ConfigKeys { /** * The URL of the bytebin instance used to upload data */ - public static final ConfigKey BYTEBIN_URL = stringKey("bytebin-url", "https://bytebin.lucko.me/"); + public static final ConfigKey BYTEBIN_URL = stringKey("bytebin-url", "https://usercontent.luckperms.net/"); + + /** + * The host of the bytesocks instance used to communicate with + */ + public static final ConfigKey BYTESOCKS_HOST = stringKey("bytesocks-host", "usersockets.luckperms.net"); /** * The URL of the web editor diff --git a/common/src/main/java/me/lucko/luckperms/common/event/EventDispatcher.java b/common/src/main/java/me/lucko/luckperms/common/event/EventDispatcher.java index b56989967..db5a774f1 100644 --- a/common/src/main/java/me/lucko/luckperms/common/event/EventDispatcher.java +++ b/common/src/main/java/me/lucko/luckperms/common/event/EventDispatcher.java @@ -40,8 +40,8 @@ import me.lucko.luckperms.common.model.HolderType; import me.lucko.luckperms.common.model.PermissionHolder; import me.lucko.luckperms.common.model.Track; import me.lucko.luckperms.common.model.User; -import me.lucko.luckperms.common.model.nodemap.MutateResult; import me.lucko.luckperms.common.sender.Sender; +import me.lucko.luckperms.common.util.Difference; import net.luckperms.api.actionlog.Action; import net.luckperms.api.event.LuckPermsEvent; @@ -226,7 +226,7 @@ public final class EventDispatcher { postAsync(LogReceiveEvent.class, id, entry); } - public void dispatchNodeChanges(PermissionHolder target, DataType dataType, MutateResult changes) { + public void dispatchNodeChanges(PermissionHolder target, DataType dataType, Difference changes) { if (!this.eventBus.shouldPost(NodeAddEvent.class) && !this.eventBus.shouldPost(NodeRemoveEvent.class)) { return; } @@ -239,15 +239,15 @@ public final class EventDispatcher { ImmutableSet state = target.getData(dataType).asImmutableSet(); // call an event for each recorded change - for (MutateResult.Change change : changes.getChanges()) { - Class type = change.getType() == MutateResult.ChangeType.ADD ? + for (Difference.Change change : changes.getChanges()) { + Class type = change.type() == Difference.ChangeType.ADD ? NodeAddEvent.class : NodeRemoveEvent.class; - postAsync(type, proxy, dataType, state, change.getNode()); + postAsync(type, proxy, dataType, state, change.value()); } } - public void dispatchNodeClear(PermissionHolder target, DataType dataType, MutateResult changes) { + public void dispatchNodeClear(PermissionHolder target, DataType dataType, Difference changes) { if (!this.eventBus.shouldPost(NodeClearEvent.class)) { return; } diff --git a/common/src/main/java/me/lucko/luckperms/common/http/BytebinClient.java b/common/src/main/java/me/lucko/luckperms/common/http/BytebinClient.java index 94326efd4..ad50d7d70 100644 --- a/common/src/main/java/me/lucko/luckperms/common/http/BytebinClient.java +++ b/common/src/main/java/me/lucko/luckperms/common/http/BytebinClient.java @@ -83,15 +83,21 @@ public class BytebinClient extends AbstractHttpClient { * * @param buf the compressed content * @param contentType the type of the content + * @param userAgentExtra extra string to append to the user agent * @return the key of the resultant content * @throws IOException if an error occurs */ - public Content postContent(byte[] buf, MediaType contentType) throws IOException, UnsuccessfulRequestException { + public Content postContent(byte[] buf, MediaType contentType, String userAgentExtra) throws IOException, UnsuccessfulRequestException { RequestBody body = RequestBody.create(contentType, buf); + String userAgent = this.userAgent; + if (userAgentExtra != null) { + userAgent += "/" + userAgentExtra; + } + Request.Builder requestBuilder = new Request.Builder() .url(this.url + "post") - .header("User-Agent", this.userAgent) + .header("User-Agent", userAgent) .header("Content-Encoding", "gzip"); Request request = requestBuilder.post(body).build(); @@ -104,6 +110,10 @@ public class BytebinClient extends AbstractHttpClient { } } + public Content postContent(byte[] buf, MediaType contentType) throws IOException, UnsuccessfulRequestException { + return postContent(buf, contentType, null); + } + /** * GETs json content from bytebin * diff --git a/common/src/main/java/me/lucko/luckperms/common/http/BytesocksClient.java b/common/src/main/java/me/lucko/luckperms/common/http/BytesocksClient.java new file mode 100644 index 000000000..61affe512 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/http/BytesocksClient.java @@ -0,0 +1,101 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.http; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; + +import java.io.IOException; +import java.util.Objects; + +public class BytesocksClient extends AbstractHttpClient { + + /* The bytesocks urls */ + private final String httpUrl; + private final String wsUrl; + + /** The client user agent */ + private final String userAgent; + + /** + * Creates a new bytesocks instance + * + * @param host the bytesocks host + * @param userAgent the client user agent string + */ + public BytesocksClient(OkHttpClient okHttpClient, String host, String userAgent) { + super(okHttpClient); + + this.httpUrl = "https://" + host + "/"; + this.wsUrl = "wss://" + host + "/"; + this.userAgent = userAgent; + } + + public Socket createSocket(WebSocketListener listener) throws IOException, UnsuccessfulRequestException { + Request createRequest = new Request.Builder() + .url(this.httpUrl + "create") + .header("User-Agent", this.userAgent) + .build(); + + String id; + try (Response response = makeHttpRequest(createRequest)) { + if (response.code() != 201) { + throw new UnsuccessfulRequestException(response); + } + + id = Objects.requireNonNull(response.header("Location")); + } + + Request socketRequest = new Request.Builder() + .url(this.wsUrl + id) + .header("User-Agent", this.userAgent) + .build(); + + return new Socket(id, this.okHttp.newWebSocket(socketRequest, listener)); + } + + public static final class Socket { + private final String channelId; + private final WebSocket socket; + + public Socket(String channelId, WebSocket socket) { + this.channelId = channelId; + this.socket = socket; + } + + public String channelId() { + return this.channelId; + } + + public WebSocket socket() { + return this.socket; + } + } + +} diff --git a/common/src/main/java/me/lucko/luckperms/common/locale/Message.java b/common/src/main/java/me/lucko/luckperms/common/locale/Message.java index 5f340c5a2..0be4e97e1 100644 --- a/common/src/main/java/me/lucko/luckperms/common/locale/Message.java +++ b/common/src/main/java/me/lucko/luckperms/common/locale/Message.java @@ -1159,6 +1159,97 @@ public interface Message { .clickEvent(ClickEvent.openUrl(url)) ); + Args0 EDITOR_SOCKET_CONNECTED = () -> prefixed(translatable() + // "&bEditor window connected successfully." + .color(AQUA) + .key("luckperms.command.editor.socket.connected") + .append(FULL_STOP) + ); + + Args0 EDITOR_SOCKET_RECONNECTED = () -> prefixed(translatable() + // "&7Editor window reconnected successfully." + .color(GRAY) + .key("luckperms.command.editor.socket.reconnected") + .append(FULL_STOP) + ); + + Args0 EDITOR_SOCKET_CHANGES_RECEIVED = () -> prefixed(translatable() + // "&7Changes have been received from the connected web editor session." + .color(GRAY) + .key("luckperms.command.editor.socket.changes-received") + .append(FULL_STOP) + ); + + Args4 EDITOR_SOCKET_UNTRUSTED = (nonce, browser, cmdLabel, console) -> join(newline(), + // "&bAn editor window has connected, but it is not yet trusted." + // "&8(&7session id = &faaaaa&7, browser = &fChrome on Windows 10&8)" + // "&7If it was you, &aclick here&7 to trust the session!" + // "&7If it was you, run &a/lp trusteditor aaaaa&7 to trust the session!" + prefixed(translatable() + .key("luckperms.command.editor.socket.untrusted") + .color(AQUA) + .append(FULL_STOP)), + prefixed(text() + .color(DARK_GRAY) + .append(OPEN_BRACKET) + .append(translatable() + .key("luckperms.command.editor.socket.untrusted.sessioninfo") + .color(GRAY) + .args( + text(nonce, WHITE), + text(browser, WHITE) + ) + ) + .append(CLOSE_BRACKET) + ), + prefixed(text() + .color(GRAY) + .apply(builder -> { + String command = "/" + cmdLabel + " trusteditor " + nonce; + if (console) { + builder.append(translatable() + .key("luckperms.command.editor.socket.untrusted.prompt.runcommand") + .args(text(command, GREEN)) + .build() + ); + } else { + builder.append(translatable() + .key("luckperms.command.editor.socket.untrusted.prompt.click") + .args(translatable() + .key("luckperms.command.editor.socket.untrusted.prompt.click.action") + .color(GREEN) + .clickEvent(ClickEvent.runCommand(command)) + ) + .build() + ); + } + })) + ); + + Args0 EDITOR_SOCKET_TRUST_SUCCESS = () -> join(newline(), + // "&aThe editor session has been marked as trusted." + // "&7In the future, connections from the same browser will be trusted automatically." + // "&7The plugin will now attempt to establish a connection with the editor..." + prefixed(translatable() + .key("luckperms.command.editor.socket.trust.success") + .color(GREEN) + .append(FULL_STOP)), + prefixed(translatable() + .key("luckperms.command.editor.socket.trust.futureinfo") + .color(GRAY) + .append(FULL_STOP)), + prefixed(translatable() + .key("luckperms.command.editor.socket.trust.connecting") + .color(GRAY)) + ); + + Args0 EDITOR_SOCKET_TRUST_FAILURE = () -> prefixed(translatable() + // "&cUnable to trust the given session because the socket is closed, or because a different connection was established instead." + .color(RED) + .key("luckperms.command.editor.socket.trust.failure") + .append(FULL_STOP) + ); + Args2 EDITOR_HTTP_REQUEST_FAILURE = (code, message) -> prefixed(text() // "&cUnable to communicate with the editor. (response code &4{}&c, message='{}')" .color(RED) diff --git a/common/src/main/java/me/lucko/luckperms/common/model/PermissionHolder.java b/common/src/main/java/me/lucko/luckperms/common/model/PermissionHolder.java index d2ab7171a..a4ebf6d7f 100644 --- a/common/src/main/java/me/lucko/luckperms/common/model/PermissionHolder.java +++ b/common/src/main/java/me/lucko/luckperms/common/model/PermissionHolder.java @@ -31,7 +31,6 @@ import me.lucko.luckperms.common.cacheddata.HolderCachedDataManager; import me.lucko.luckperms.common.cacheddata.type.MetaAccumulator; import me.lucko.luckperms.common.inheritance.InheritanceComparator; import me.lucko.luckperms.common.inheritance.InheritanceGraph; -import me.lucko.luckperms.common.model.nodemap.MutateResult; import me.lucko.luckperms.common.model.nodemap.NodeMap; import me.lucko.luckperms.common.model.nodemap.NodeMapMutable; import me.lucko.luckperms.common.model.nodemap.RecordedNodeMap; @@ -39,6 +38,7 @@ import me.lucko.luckperms.common.node.NodeEquality; import me.lucko.luckperms.common.node.comparator.NodeWithContextComparator; import me.lucko.luckperms.common.plugin.LuckPermsPlugin; import me.lucko.luckperms.common.query.DataSelector; +import me.lucko.luckperms.common.util.Difference; import net.kyori.adventure.text.Component; import net.luckperms.api.context.ContextSet; @@ -223,8 +223,17 @@ public abstract class PermissionHolder { invalidateCache(); } - public MutateResult setNodes(DataType type, Iterable set, boolean callEvent) { - MutateResult res = getData(type).setContent(set); + public Difference setNodes(DataType type, Iterable set, boolean callEvent) { + Difference res = getData(type).setContent(set); + invalidateCache(); + if (callEvent) { + getPlugin().getEventDispatcher().dispatchNodeChanges(this, type, res); + } + return res; + } + + public Difference setNodes(DataType type, Difference changes, boolean callEvent) { + Difference res = getData(type).applyChanges(changes); invalidateCache(); if (callEvent) { getPlugin().getEventDispatcher().dispatchNodeChanges(this, type, res); @@ -420,7 +429,7 @@ public abstract class PermissionHolder { } private boolean auditTemporaryNodes(DataType dataType) { - MutateResult result = getData(dataType).removeIf(Node::hasExpired); + Difference result = getData(dataType).removeIf(Node::hasExpired); if (!result.isEmpty()) { invalidateCache(); } @@ -454,7 +463,7 @@ public abstract class PermissionHolder { return DataMutateResult.FAIL_ALREADY_HAS; } - MutateResult changes = getData(dataType).add(node); + Difference changes = getData(dataType).add(node); invalidateCache(); if (callEvent) { this.plugin.getEventDispatcher().dispatchNodeChanges(this, dataType, changes); @@ -491,7 +500,7 @@ public abstract class PermissionHolder { if (newNode != null) { // Remove the old Node & add the new one. - MutateResult changes = data.removeThenAdd(otherMatch, newNode); + Difference changes = data.removeThenAdd(otherMatch, newNode); invalidateCache(); this.plugin.getEventDispatcher().dispatchNodeChanges(this, dataType, changes); @@ -509,7 +518,7 @@ public abstract class PermissionHolder { return DataMutateResult.FAIL_LACKS; } - MutateResult changes = getData(dataType).remove(node); + Difference changes = getData(dataType).remove(node); invalidateCache(); this.plugin.getEventDispatcher().dispatchNodeChanges(this, dataType, changes); @@ -531,7 +540,7 @@ public abstract class PermissionHolder { Node newNode = node.toBuilder().expiry(newExpiry).build(); // Remove the old Node & add the new one. - MutateResult changes = data.removeThenAdd(otherMatch, newNode); + Difference changes = data.removeThenAdd(otherMatch, newNode); invalidateCache(); this.plugin.getEventDispatcher().dispatchNodeChanges(this, dataType, changes); @@ -545,7 +554,7 @@ public abstract class PermissionHolder { } public boolean removeIf(DataType dataType, @Nullable ContextSet contextSet, Predicate predicate, boolean giveDefault) { - MutateResult changes; + Difference changes; if (contextSet == null) { changes = getData(dataType).removeIf(predicate); } else { @@ -566,7 +575,7 @@ public abstract class PermissionHolder { } public boolean clearNodes(DataType dataType, ContextSet contextSet, boolean giveDefault) { - MutateResult changes; + Difference changes; if (contextSet == null) { changes = getData(dataType).clear(); } else { diff --git a/common/src/main/java/me/lucko/luckperms/common/model/nodemap/MutateResult.java b/common/src/main/java/me/lucko/luckperms/common/model/nodemap/MutateResult.java deleted file mode 100644 index aeb554c1f..000000000 --- a/common/src/main/java/me/lucko/luckperms/common/model/nodemap/MutateResult.java +++ /dev/null @@ -1,152 +0,0 @@ -/* - * This file is part of LuckPerms, licensed under the MIT License. - * - * Copyright (c) lucko (Luck) - * Copyright (c) contributors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package me.lucko.luckperms.common.model.nodemap; - -import net.luckperms.api.node.Node; - -import java.util.LinkedHashSet; -import java.util.Objects; -import java.util.Set; - -/** - * Records a log of the changes that occur as a result of a {@link NodeMap} mutation(s). - */ -public class MutateResult { - private final LinkedHashSet changes = new LinkedHashSet<>(); - - public Set getChanges() { - return this.changes; - } - - public Set getChanges(ChangeType type) { - Set changes = new LinkedHashSet<>(this.changes.size()); - for (Change change : this.changes) { - if (change.getType() == type) { - changes.add(change.getNode()); - } - } - return changes; - } - - void clear() { - this.changes.clear(); - } - - public boolean isEmpty() { - return this.changes.isEmpty(); - } - - public Set getAdded() { - return getChanges(ChangeType.ADD); - } - - public Set getRemoved() { - return getChanges(ChangeType.REMOVE); - } - - private void recordChange(Change change) { - // This method is the magic of this class. - // When tracking, we want to ignore changes that cancel each other out, and only - // keep track of the net difference. - // e.g. adding then removing the same node = zero net change, so ignore it. - - if (this.changes.remove(change.inverse())) { - return; - } - this.changes.add(change); - } - - public void recordChange(ChangeType type, Node node) { - recordChange(new Change(type, node)); - } - - public void recordChanges(ChangeType type, Iterable nodes) { - for (Node node : nodes) { - recordChange(new Change(type, node)); - } - } - - public MutateResult mergeFrom(MutateResult other) { - for (Change change : other.changes) { - recordChange(change); - } - return this; - } - - @Override - public String toString() { - return "MutateResult{changes=" + this.changes + '}'; - } - - public static final class Change { - private final ChangeType type; - private final Node node; - - public Change(ChangeType type, Node node) { - this.type = type; - this.node = node; - } - - public ChangeType getType() { - return this.type; - } - - public Node getNode() { - return this.node; - } - - public Change inverse() { - return new Change(this.type.inverse(), this.node); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Change change = (Change) o; - return this.type == change.type && this.node.equals(change.node); - } - - @Override - public int hashCode() { - return Objects.hash(this.type, this.node); - } - - @Override - public String toString() { - return "Change{type=" + this.type + ", node=" + this.node + '}'; - } - } - - public enum ChangeType { - ADD, REMOVE; - - public ChangeType inverse() { - return this == ADD ? REMOVE : ADD; - } - } - -} diff --git a/common/src/main/java/me/lucko/luckperms/common/model/nodemap/NodeMap.java b/common/src/main/java/me/lucko/luckperms/common/model/nodemap/NodeMap.java index 117045cac..8e4a0b9ab 100644 --- a/common/src/main/java/me/lucko/luckperms/common/model/nodemap/NodeMap.java +++ b/common/src/main/java/me/lucko/luckperms/common/model/nodemap/NodeMap.java @@ -30,6 +30,7 @@ import com.google.common.collect.ImmutableSet; import me.lucko.luckperms.common.model.PermissionHolder; import me.lucko.luckperms.common.node.comparator.NodeWithContextComparator; +import me.lucko.luckperms.common.util.Difference; import net.luckperms.api.context.ContextSet; import net.luckperms.api.context.ImmutableContextSet; @@ -134,28 +135,30 @@ public interface NodeMap { // mutate methods - MutateResult add(Node nodeWithoutInheritanceOrigin); + Difference add(Node nodeWithoutInheritanceOrigin); - MutateResult remove(Node node); + Difference remove(Node node); - MutateResult removeExact(Node node); + Difference removeExact(Node node); - MutateResult removeIf(Predicate predicate); + Difference removeIf(Predicate predicate); - MutateResult removeIf(ContextSet contextSet, Predicate predicate); + Difference removeIf(ContextSet contextSet, Predicate predicate); - MutateResult removeThenAdd(Node nodeToRemove, Node nodeToAdd); + Difference removeThenAdd(Node nodeToRemove, Node nodeToAdd); - MutateResult clear(); + Difference clear(); - MutateResult clear(ContextSet contextSet); + Difference clear(ContextSet contextSet); - MutateResult setContent(Iterable set); + Difference setContent(Iterable set); - MutateResult setContent(Stream stream); + Difference setContent(Stream stream); - MutateResult addAll(Iterable set); + Difference applyChanges(Difference changes); - MutateResult addAll(Stream stream); + Difference addAll(Iterable set); + + Difference addAll(Stream stream); } diff --git a/common/src/main/java/me/lucko/luckperms/common/model/nodemap/NodeMapMutable.java b/common/src/main/java/me/lucko/luckperms/common/model/nodemap/NodeMapMutable.java index 97db73846..291bb9a32 100644 --- a/common/src/main/java/me/lucko/luckperms/common/model/nodemap/NodeMapMutable.java +++ b/common/src/main/java/me/lucko/luckperms/common/model/nodemap/NodeMapMutable.java @@ -29,8 +29,9 @@ import me.lucko.luckperms.common.config.ConfigKeys; import me.lucko.luckperms.common.context.comparator.ContextSetComparator; import me.lucko.luckperms.common.model.InheritanceOrigin; import me.lucko.luckperms.common.model.PermissionHolder; -import me.lucko.luckperms.common.model.nodemap.MutateResult.ChangeType; import me.lucko.luckperms.common.node.comparator.NodeComparator; +import me.lucko.luckperms.common.util.Difference; +import me.lucko.luckperms.common.util.Difference.ChangeType; import net.luckperms.api.context.ContextSatisfyMode; import net.luckperms.api.context.ContextSet; @@ -122,11 +123,11 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult add(Node nodeWithoutInheritanceOrigin) { + public Difference add(Node nodeWithoutInheritanceOrigin) { Node node = addInheritanceOrigin(nodeWithoutInheritanceOrigin); ImmutableContextSet context = node.getContexts(); - MutateResult result = new MutateResult(); + Difference result = new Difference<>(); this.lock.lock(); try { @@ -162,9 +163,9 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult remove(Node node) { + public Difference remove(Node node) { ImmutableContextSet context = node.getContexts(); - MutateResult result = new MutateResult(); + Difference result = new Difference<>(); this.lock.lock(); try { @@ -191,7 +192,7 @@ public class NodeMapMutable extends NodeMapBase { return result; } - private static void removeMatching(Iterator it, Node node, MutateResult result) { + private static void removeMatching(Iterator it, Node node, Difference result) { while (it.hasNext()) { Node el = it.next(); if (node.equals(el, NodeEqualityPredicate.IGNORE_EXPIRY_TIME_AND_VALUE)) { @@ -201,7 +202,7 @@ public class NodeMapMutable extends NodeMapBase { } } - private static void removeMatchingButNotSame(Iterator it, Node node, MutateResult result) { + private static void removeMatchingButNotSame(Iterator it, Node node, Difference result) { while (it.hasNext()) { Node el = it.next(); if (el != node && node.equals(el, NodeEqualityPredicate.IGNORE_EXPIRY_TIME_AND_VALUE)) { @@ -212,9 +213,9 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult removeExact(Node node) { + public Difference removeExact(Node node) { ImmutableContextSet context = node.getContexts(); - MutateResult result = new MutateResult(); + Difference result = new Difference<>(); this.lock.lock(); try { @@ -245,8 +246,8 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult removeIf(Predicate predicate) { - MutateResult result = new MutateResult(); + public Difference removeIf(Predicate predicate) { + Difference result = new Difference<>(); this.lock.lock(); try { @@ -261,9 +262,9 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult removeIf(ContextSet contextSet, Predicate predicate) { + public Difference removeIf(ContextSet contextSet, Predicate predicate) { ImmutableContextSet context = contextSet.immutableCopy(); - MutateResult result = new MutateResult(); + Difference result = new Difference<>(); this.lock.lock(); try { @@ -279,7 +280,7 @@ public class NodeMapMutable extends NodeMapBase { return result; } - private void removeMatching(Iterator it, Predicate predicate, MutateResult result) { + private void removeMatching(Iterator it, Predicate predicate, Difference result) { while (it.hasNext()) { Node node = it.next(); @@ -300,9 +301,9 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult removeThenAdd(Node nodeToRemove, Node nodeToAdd) { + public Difference removeThenAdd(Node nodeToRemove, Node nodeToAdd) { if (nodeToAdd.equals(nodeToRemove)) { - return new MutateResult(); + return new Difference<>(); } this.lock.lock(); @@ -314,8 +315,8 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult clear() { - MutateResult result = new MutateResult(); + public Difference clear() { + Difference result = new Difference<>(); this.lock.lock(); try { @@ -336,9 +337,9 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult clear(ContextSet contextSet) { + public Difference clear(ContextSet contextSet) { ImmutableContextSet context = contextSet.immutableCopy(); - MutateResult result = new MutateResult(); + Difference result = new Difference<>(); this.lock.lock(); try { @@ -355,8 +356,8 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult setContent(Iterable set) { - MutateResult result = new MutateResult(); + public Difference setContent(Iterable set) { + Difference result = new Difference<>(); this.lock.lock(); try { @@ -370,8 +371,8 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult setContent(Stream stream) { - MutateResult result = new MutateResult(); + public Difference setContent(Stream stream) { + Difference result = new Difference<>(); this.lock.lock(); try { @@ -385,8 +386,27 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult addAll(Iterable set) { - MutateResult result = new MutateResult(); + public Difference applyChanges(Difference changes) { + Difference result = new Difference<>(); + + this.lock.lock(); + try { + for (Node n : changes.getAdded()) { + result.mergeFrom(add(n)); + } + for (Node n : changes.getRemoved()) { + result.mergeFrom(removeExact(n)); + } + } finally { + this.lock.unlock(); + } + + return result; + } + + @Override + public Difference addAll(Iterable set) { + Difference result = new Difference<>(); this.lock.lock(); try { @@ -401,8 +421,8 @@ public class NodeMapMutable extends NodeMapBase { } @Override - public MutateResult addAll(Stream stream) { - MutateResult result = new MutateResult(); + public Difference addAll(Stream stream) { + Difference result = new Difference<>(); this.lock.lock(); try { diff --git a/common/src/main/java/me/lucko/luckperms/common/model/nodemap/RecordedNodeMap.java b/common/src/main/java/me/lucko/luckperms/common/model/nodemap/RecordedNodeMap.java index 10d24cad9..37eebf274 100644 --- a/common/src/main/java/me/lucko/luckperms/common/model/nodemap/RecordedNodeMap.java +++ b/common/src/main/java/me/lucko/luckperms/common/model/nodemap/RecordedNodeMap.java @@ -28,6 +28,8 @@ package me.lucko.luckperms.common.model.nodemap; import com.google.common.collect.ImmutableCollection; import com.google.common.collect.ImmutableSet; +import me.lucko.luckperms.common.util.Difference; + import net.luckperms.api.context.ContextSet; import net.luckperms.api.context.ImmutableContextSet; import net.luckperms.api.node.Node; @@ -53,7 +55,7 @@ public class RecordedNodeMap implements NodeMap { private final NodeMap delegate; private final Lock lock = new ReentrantLock(); - private MutateResult changes = new MutateResult(); + private Difference changes = new Difference<>(); public RecordedNodeMap(NodeMap delegate) { this.delegate = delegate; @@ -72,12 +74,12 @@ public class RecordedNodeMap implements NodeMap { } } - public MutateResult exportChanges(Predicate onlyIf) { + public Difference exportChanges(Predicate> onlyIf) { this.lock.lock(); try { - MutateResult existing = this.changes; + Difference existing = this.changes; if (onlyIf.test(existing)) { - this.changes = new MutateResult(); + this.changes = new Difference<>(); return existing; } return null; @@ -86,7 +88,7 @@ public class RecordedNodeMap implements NodeMap { } } - private MutateResult record(MutateResult result) { + private Difference record(Difference result) { this.lock.lock(); try { this.changes.mergeFrom(result); @@ -99,62 +101,67 @@ public class RecordedNodeMap implements NodeMap { // delegate, but pass the result through #record(MutateResult) @Override - public MutateResult add(Node nodeWithoutInheritanceOrigin) { + public Difference add(Node nodeWithoutInheritanceOrigin) { return record(this.delegate.add(nodeWithoutInheritanceOrigin)); } @Override - public MutateResult remove(Node node) { + public Difference remove(Node node) { return record(this.delegate.remove(node)); } @Override - public MutateResult removeExact(Node node) { + public Difference removeExact(Node node) { return record(this.delegate.removeExact(node)); } @Override - public MutateResult removeIf(Predicate predicate) { + public Difference removeIf(Predicate predicate) { return record(this.delegate.removeIf(predicate)); } @Override - public MutateResult removeIf(ContextSet contextSet, Predicate predicate) { + public Difference removeIf(ContextSet contextSet, Predicate predicate) { return record(this.delegate.removeIf(contextSet, predicate)); } @Override - public MutateResult removeThenAdd(Node nodeToRemove, Node nodeToAdd) { + public Difference removeThenAdd(Node nodeToRemove, Node nodeToAdd) { return record(this.delegate.removeThenAdd(nodeToRemove, nodeToAdd)); } @Override - public MutateResult clear() { + public Difference clear() { return record(this.delegate.clear()); } @Override - public MutateResult clear(ContextSet contextSet) { + public Difference clear(ContextSet contextSet) { return record(this.delegate.clear(contextSet)); } @Override - public MutateResult setContent(Iterable set) { + public Difference setContent(Iterable set) { return record(this.delegate.setContent(set)); } @Override - public MutateResult setContent(Stream stream) { + public Difference setContent(Stream stream) { return record(this.delegate.setContent(stream)); } @Override - public MutateResult addAll(Iterable set) { + public Difference applyChanges(Difference changes) { + return record(this.delegate.applyChanges(changes)); + } + + @Override + public Difference addAll(Iterable set) { return record(this.delegate.addAll(set)); } @Override - public MutateResult addAll(Stream stream) { + public Difference addAll(Stream stream) { return record(this.delegate.addAll(stream)); } diff --git a/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java b/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java index 871fefa99..4836b9e76 100644 --- a/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java +++ b/common/src/main/java/me/lucko/luckperms/common/plugin/AbstractLuckPermsPlugin.java @@ -40,6 +40,7 @@ import me.lucko.luckperms.common.event.EventDispatcher; import me.lucko.luckperms.common.event.gen.GeneratedEventClass; import me.lucko.luckperms.common.extension.SimpleExtensionManager; import me.lucko.luckperms.common.http.BytebinClient; +import me.lucko.luckperms.common.http.BytesocksClient; import me.lucko.luckperms.common.inheritance.InheritanceGraphFactory; import me.lucko.luckperms.common.locale.Message; import me.lucko.luckperms.common.locale.TranslationManager; @@ -57,7 +58,7 @@ import me.lucko.luckperms.common.tasks.ExpireTemporaryTask; import me.lucko.luckperms.common.tasks.SyncTask; import me.lucko.luckperms.common.treeview.PermissionRegistry; import me.lucko.luckperms.common.verbose.VerboseHandler; -import me.lucko.luckperms.common.webeditor.WebEditorSessionStore; +import me.lucko.luckperms.common.webeditor.store.WebEditorStore; import net.luckperms.api.LuckPerms; @@ -90,7 +91,8 @@ public abstract class AbstractLuckPermsPlugin implements LuckPermsPlugin { private LogDispatcher logDispatcher; private LuckPermsConfiguration configuration; private BytebinClient bytebin; - private WebEditorSessionStore webEditorSessionStore; + private BytesocksClient bytesocks; + private WebEditorStore webEditorStore; private TranslationRepository translationRepository; private FileWatcher fileWatcher = null; private Storage storage; @@ -136,7 +138,8 @@ public abstract class AbstractLuckPermsPlugin implements LuckPermsPlugin { .build(); this.bytebin = new BytebinClient(httpClient, getConfiguration().get(ConfigKeys.BYTEBIN_URL), "luckperms"); - this.webEditorSessionStore = new WebEditorSessionStore(); + this.bytesocks = new BytesocksClient(httpClient, getConfiguration().get(ConfigKeys.BYTESOCKS_HOST), "luckperms/editor"); + this.webEditorStore = new WebEditorStore(this); // init translation repo and update bundle files this.translationRepository = new TranslationRepository(this); @@ -420,8 +423,13 @@ public abstract class AbstractLuckPermsPlugin implements LuckPermsPlugin { } @Override - public WebEditorSessionStore getWebEditorSessionStore() { - return this.webEditorSessionStore; + public BytesocksClient getBytesocks() { + return this.bytesocks; + } + + @Override + public WebEditorStore getWebEditorStore() { + return this.webEditorStore; } @Override diff --git a/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java b/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java index 89e83ada1..e6c5ab0c2 100644 --- a/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java +++ b/common/src/main/java/me/lucko/luckperms/common/plugin/LuckPermsPlugin.java @@ -36,6 +36,7 @@ import me.lucko.luckperms.common.dependencies.DependencyManager; import me.lucko.luckperms.common.event.EventDispatcher; import me.lucko.luckperms.common.extension.SimpleExtensionManager; import me.lucko.luckperms.common.http.BytebinClient; +import me.lucko.luckperms.common.http.BytesocksClient; import me.lucko.luckperms.common.inheritance.InheritanceGraphFactory; import me.lucko.luckperms.common.locale.TranslationManager; import me.lucko.luckperms.common.locale.TranslationRepository; @@ -55,7 +56,7 @@ import me.lucko.luckperms.common.storage.implementation.file.watcher.FileWatcher import me.lucko.luckperms.common.tasks.SyncTask; import me.lucko.luckperms.common.treeview.PermissionRegistry; import me.lucko.luckperms.common.verbose.VerboseHandler; -import me.lucko.luckperms.common.webeditor.WebEditorSessionStore; +import me.lucko.luckperms.common.webeditor.store.WebEditorStore; import net.luckperms.api.query.QueryOptions; @@ -250,11 +251,18 @@ public interface LuckPermsPlugin { BytebinClient getBytebin(); /** - * Gets the web editor session store + * Gets the bytesocks instance in use by platform. * - * @return the web editor session store + * @return the bytesocks instance */ - WebEditorSessionStore getWebEditorSessionStore(); + BytesocksClient getBytesocks(); + + /** + * Gets the web editor store + * + * @return the web editor store + */ + WebEditorStore getWebEditorStore(); /** * Gets a calculated context instance for the user using the rules of the platform. diff --git a/common/src/main/java/me/lucko/luckperms/common/storage/implementation/sql/SqlStorage.java b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/sql/SqlStorage.java index 1e16da190..ee7d653ab 100644 --- a/common/src/main/java/me/lucko/luckperms/common/storage/implementation/sql/SqlStorage.java +++ b/common/src/main/java/me/lucko/luckperms/common/storage/implementation/sql/SqlStorage.java @@ -39,7 +39,6 @@ import me.lucko.luckperms.common.model.Group; import me.lucko.luckperms.common.model.Track; import me.lucko.luckperms.common.model.User; import me.lucko.luckperms.common.model.manager.group.GroupManager; -import me.lucko.luckperms.common.model.nodemap.MutateResult; import me.lucko.luckperms.common.node.factory.NodeBuilders; import me.lucko.luckperms.common.node.matcher.ConstraintNodeMatcher; import me.lucko.luckperms.common.plugin.LuckPermsPlugin; @@ -47,6 +46,7 @@ import me.lucko.luckperms.common.storage.implementation.StorageImplementation; import me.lucko.luckperms.common.storage.implementation.sql.connection.ConnectionFactory; import me.lucko.luckperms.common.storage.misc.NodeEntry; import me.lucko.luckperms.common.storage.misc.PlayerSaveResultImpl; +import me.lucko.luckperms.common.util.Difference; import me.lucko.luckperms.common.util.Uuids; import me.lucko.luckperms.common.util.gson.GsonProvider; @@ -351,15 +351,15 @@ public class SqlStorage implements StorageImplementation { @Override public void saveUser(User user) throws SQLException { - MutateResult changes = user.normalData().exportChanges(results -> { + Difference changes = user.normalData().exportChanges(results -> { if (this.plugin.getUserManager().isNonDefaultUser(user)) { return true; } // if the only change is adding the default node, we don't need to export if (results.getChanges().size() == 1) { - MutateResult.Change onlyChange = results.getChanges().iterator().next(); - return !(onlyChange.getType() == MutateResult.ChangeType.ADD && this.plugin.getUserManager().isDefaultNode(onlyChange.getNode())); + Difference.Change onlyChange = results.getChanges().iterator().next(); + return !(onlyChange.type() == Difference.ChangeType.ADD && this.plugin.getUserManager().isDefaultNode(onlyChange.value())); } return true; @@ -480,7 +480,7 @@ public class SqlStorage implements StorageImplementation { @Override public void saveGroup(Group group) throws SQLException { - MutateResult changes = group.normalData().exportChanges(c -> true); + Difference changes = group.normalData().exportChanges(c -> true); if (!changes.isEmpty()) { try (Connection c = this.connectionFactory.getConnection()) { diff --git a/common/src/main/java/me/lucko/luckperms/common/util/Difference.java b/common/src/main/java/me/lucko/luckperms/common/util/Difference.java new file mode 100644 index 000000000..d0a56fd29 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/util/Difference.java @@ -0,0 +1,208 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.util; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * Records a log of the changes that occur as a result + * of mutations (add or remove operations). + * + * @param the value type + */ +public class Difference { + private final LinkedHashSet> changes = new LinkedHashSet<>(); + + /** + * Gets the recorded changes. + * + * @return the changes + */ + public Set> getChanges() { + return this.changes; + } + + /** + * Gets if no changes have been recorded. + * + * @return if no changes have been recorded + */ + public boolean isEmpty() { + return this.changes.isEmpty(); + } + + /** + * Gets the recorded changes of a given type + * + * @param type the type of change + * @return the changes + */ + public Set getChanges(ChangeType type) { + Set changes = new LinkedHashSet<>(this.changes.size()); + for (Change change : this.changes) { + if (change.type() == type) { + changes.add(change.value()); + } + } + return changes; + } + + /** + * Gets the values that have been added. + * + * @return the added values + */ + public Set getAdded() { + return getChanges(ChangeType.ADD); + } + + /** + * Gets the values that have been removed. + * + * @return the removed values + */ + public Set getRemoved() { + return getChanges(ChangeType.REMOVE); + } + + /** + * Clears all recorded changes. + */ + public void clear() { + this.changes.clear(); + } + + private void recordChange(Change change) { + // This method is the magic of this class. + // When tracking, we want to ignore changes that cancel each other out, and only + // keep track of the net difference. + // e.g. adding then removing the same value = zero net change, so ignore it. + + if (this.changes.remove(change.inverse())) { + return; + } + this.changes.add(change); + } + + /** + * Records a change. + * + * @param type the type of change + * @param value the changed value + */ + public void recordChange(ChangeType type, T value) { + recordChange(new Change<>(type, value)); + } + + /** + * Records some changes. + * + * @param type the type of change + * @param values the changed values + */ + public void recordChanges(ChangeType type, Iterable values) { + for (T value : values) { + recordChange(new Change<>(type, value)); + } + } + + /** + * Merges the recorded differences in {@code other} into this. + * + * @param other the other differences + * @return this + */ + public Difference mergeFrom(Difference other) { + for (Change change : other.changes) { + recordChange(change); + } + return this; + } + + @Override + public String toString() { + return "Difference{" + this.changes + '}'; + } + + /** + * A single change recorded in the {@link Difference} tracker. + * + * @param the value type + */ + public static final class Change { + private final ChangeType type; + private final T value; + + public Change(ChangeType type, T value) { + this.type = type; + this.value = value; + } + + public ChangeType type() { + return this.type; + } + + public T value() { + return this.value; + } + + public Change inverse() { + return new Change<>(this.type.inverse(), this.value); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Change change = (Change) o; + return this.type == change.type && this.value.equals(change.value); + } + + @Override + public int hashCode() { + return Objects.hash(this.type, this.value); + } + + @Override + public String toString() { + return "(" + this.type + ": " + this.value + ')'; + } + } + + /** + * The type of change. + */ + public enum ChangeType { + ADD, REMOVE; + + public ChangeType inverse() { + return this == ADD ? REMOVE : ADD; + } + } + +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorRequest.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorRequest.java index 0a7ce368f..f3982d2ed 100644 --- a/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorRequest.java +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorRequest.java @@ -28,14 +28,11 @@ package me.lucko.luckperms.common.webeditor; import com.google.common.base.Preconditions; import com.google.gson.JsonObject; -import me.lucko.luckperms.common.config.ConfigKeys; import me.lucko.luckperms.common.context.ImmutableContextSetImpl; import me.lucko.luckperms.common.context.serializer.ContextSetJsonSerializer; -import me.lucko.luckperms.common.http.AbstractHttpClient; -import me.lucko.luckperms.common.http.UnsuccessfulRequestException; -import me.lucko.luckperms.common.locale.Message; import me.lucko.luckperms.common.model.Group; import me.lucko.luckperms.common.model.PermissionHolder; +import me.lucko.luckperms.common.model.PermissionHolderIdentifier; import me.lucko.luckperms.common.model.Track; import me.lucko.luckperms.common.model.User; import me.lucko.luckperms.common.node.matcher.ConstraintNodeMatcher; @@ -43,6 +40,7 @@ import me.lucko.luckperms.common.node.utils.NodeJsonSerializer; import me.lucko.luckperms.common.plugin.LuckPermsPlugin; import me.lucko.luckperms.common.sender.Sender; import me.lucko.luckperms.common.storage.misc.NodeEntry; +import me.lucko.luckperms.common.util.ImmutableCollectors; import me.lucko.luckperms.common.util.gson.GsonProvider; import me.lucko.luckperms.common.util.gson.JArray; import me.lucko.luckperms.common.util.gson.JObject; @@ -64,6 +62,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Stream; import java.util.zip.GZIPOutputStream; @@ -75,6 +74,48 @@ public class WebEditorRequest { public static final int MAX_USERS = 500; + /** + * The encoded json object this payload is made up of + */ + private final JsonObject payload; + + private final Map> holders; + private final Map> tracks; + + private WebEditorRequest(JsonObject payload, Map> holders, Map> tracks) { + this.payload = payload; + this.holders = holders.entrySet().stream().collect(ImmutableCollectors.toMap( + e -> e.getKey().getIdentifier(), + Map.Entry::getValue + )); + this.tracks = tracks.entrySet().stream().collect(ImmutableCollectors.toMap( + e -> e.getKey().getName(), + Map.Entry::getValue + )); + } + + public JsonObject getPayload() { + return this.payload; + } + + public byte[] encode() { + ByteArrayOutputStream bytesOut = new ByteArrayOutputStream(); + try (Writer writer = new OutputStreamWriter(new GZIPOutputStream(bytesOut), StandardCharsets.UTF_8)) { + GsonProvider.normal().toJson(this.payload, writer); + } catch (IOException e) { + e.printStackTrace(); + } + return bytesOut.toByteArray(); + } + + public Map> getHolders() { + return this.holders; + } + + public Map> getTracks() { + return this.tracks; + } + /** * Generates a web editor request payload. * @@ -95,34 +136,35 @@ public class WebEditorRequest { } // form the payload data - return new WebEditorRequest(holders, tracks, sender, cmdLabel, potentialContexts.build(), plugin); + Map> holdersMap = holders.stream().collect(ImmutableCollectors.toMap( + Function.identity(), + holder -> holder.normalData().asList() + )); + + Map> tracksMap = tracks.stream().collect(ImmutableCollectors.toMap( + Function.identity(), + Track::getGroups + )); + + JsonObject json = createJsonPayload(holdersMap, tracksMap, sender, cmdLabel, potentialContexts.build(), plugin).toJson(); + return new WebEditorRequest(json, holdersMap, tracksMap); } - /** - * The encoded json object this payload is made up of - */ - private final JsonObject payload; - - private WebEditorRequest(List holders, List tracks, Sender sender, String cmdLabel, ImmutableContextSet potentialContexts, LuckPermsPlugin plugin) { - this.payload = new JObject() + private static JObject createJsonPayload(Map> holders, Map> tracks, Sender sender, String cmdLabel, ImmutableContextSet potentialContexts, LuckPermsPlugin plugin) { + return new JObject() .add("metadata", formMetadata(sender, cmdLabel, plugin.getBootstrap().getVersion())) - .add("permissionHolders", new JArray() - .consume(arr -> { - for (PermissionHolder holder : holders) { - arr.add(formPermissionHolder(holder)); - } - }) - ) - .add("tracks", new JArray() - .consume(arr -> { - for (Track track : tracks) { - arr.add(formTrack(track)); - } - }) - ) + .add("permissionHolders", new JArray().consume(arr -> + holders.forEach((holder, data) -> + arr.add(formPermissionHolder(holder, data)) + ) + )) + .add("tracks", new JArray().consume(arr -> + tracks.forEach((track, data) -> + arr.add(formTrack(track, data)) + ) + )) .add("knownPermissions", new JArray().addAll(plugin.getPermissionRegistry().rootAsList())) - .add("potentialContexts", ContextSetJsonSerializer.serialize(potentialContexts)) - .toJson(); + .add("potentialContexts", ContextSetJsonSerializer.serialize(potentialContexts)); } private static JObject formMetadata(Sender sender, String cmdLabel, String pluginVersion) { @@ -136,56 +178,19 @@ public class WebEditorRequest { .add("pluginVersion", pluginVersion); } - private static JObject formPermissionHolder(PermissionHolder holder) { + private static JObject formPermissionHolder(PermissionHolder holder, List data) { return new JObject() .add("type", holder.getType().toString()) .add("id", holder.getIdentifier().getName()) .add("displayName", holder.getPlainDisplayName()) - .add("nodes", NodeJsonSerializer.serializeNodes(holder.normalData().asList())); + .add("nodes", NodeJsonSerializer.serializeNodes(data)); } - private static JObject formTrack(Track track) { + private static JObject formTrack(Track track, List data) { return new JObject() .add("type", "track") .add("id", track.getName()) - .add("groups", new JArray().addAll(track.getGroups())); - } - - public byte[] encode() { - ByteArrayOutputStream bytesOut = new ByteArrayOutputStream(); - try (Writer writer = new OutputStreamWriter(new GZIPOutputStream(bytesOut), StandardCharsets.UTF_8)) { - GsonProvider.normal().toJson(this.payload, writer); - } catch (IOException e) { - e.printStackTrace(); - } - return bytesOut.toByteArray(); - } - - /** - * Creates a web editor session, and sends the URL to the sender. - * - * @param plugin the plugin - * @param sender the sender creating the session - * @return the command result - */ - public void createSession(LuckPermsPlugin plugin, Sender sender) { - String pasteId; - try { - pasteId = plugin.getBytebin().postContent(encode(), AbstractHttpClient.JSON_TYPE).key(); - } catch (UnsuccessfulRequestException e) { - Message.EDITOR_HTTP_REQUEST_FAILURE.send(sender, e.getResponse().code(), e.getResponse().message()); - return; - } catch (IOException e) { - new RuntimeException("Error uploading data to bytebin", e).printStackTrace(); - Message.EDITOR_HTTP_UNKNOWN_FAILURE.send(sender); - return; - } - - plugin.getWebEditorSessionStore().addNewSession(pasteId); - - // form a url for the editor - String url = plugin.getConfiguration().get(ConfigKeys.WEB_EDITOR_URL_PATTERN) + pasteId; - Message.EDITOR_URL.send(sender, url); + .add("groups", new JArray().addAll(data)); } public static void includeMatchingGroups(List holders, Predicate filter, LuckPermsPlugin plugin) { diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorResponse.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorResponse.java index b90bcd967..8cd9eb054 100644 --- a/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorResponse.java +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorResponse.java @@ -40,11 +40,12 @@ import me.lucko.luckperms.common.model.PermissionHolder; import me.lucko.luckperms.common.model.Track; import me.lucko.luckperms.common.model.User; import me.lucko.luckperms.common.model.manager.group.GroupManager; -import me.lucko.luckperms.common.model.nodemap.MutateResult; import me.lucko.luckperms.common.node.utils.NodeJsonSerializer; import me.lucko.luckperms.common.plugin.LuckPermsPlugin; import me.lucko.luckperms.common.sender.Sender; +import me.lucko.luckperms.common.util.Difference; import me.lucko.luckperms.common.util.Uuids; +import me.lucko.luckperms.common.webeditor.store.RemoteSession; import net.kyori.adventure.text.Component; import net.luckperms.api.actionlog.Action; @@ -55,7 +56,6 @@ import net.luckperms.api.node.Node; import java.util.ArrayList; import java.util.Collection; -import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.UUID; @@ -86,41 +86,31 @@ public class WebEditorResponse { * @param plugin the plugin * @param sender the sender who is applying the session */ - public void apply(LuckPermsPlugin plugin, Sender sender, String commandLabel, boolean ignoreSessionWarning) { - JsonElement sessionIdJson = this.payload.get("sessionId"); - if (sessionIdJson != null) { - String sessionId = sessionIdJson.getAsString(); - WebEditorSessionStore sessionStore = plugin.getWebEditorSessionStore(); + public void apply(LuckPermsPlugin plugin, Sender sender, WebEditorSession editorSession, String commandLabel, boolean ignoreSessionWarning) { + String sessionId = this.payload.get("sessionId").getAsString(); + RemoteSession remoteSession = plugin.getWebEditorStore().sessions().getSession(sessionId); - SessionState state = sessionStore.getSessionState(sessionId); - switch (state) { - case COMPLETED: - if (!ignoreSessionWarning) { - Message.APPLY_EDITS_SESSION_APPLIED_ALREADY.send(sender, this.id, commandLabel); - return; - } - break; - case NOT_KNOWN: - if (!ignoreSessionWarning) { - Message.APPLY_EDITS_SESSION_UNKNOWN.send(sender, this.id, commandLabel); - return; - } - break; - case IN_PROGRESS: - sessionStore.markSessionCompleted(sessionId); - break; - default: - throw new AssertionError(state); + if (remoteSession == null) { + // session is unknown + if (!ignoreSessionWarning) { + Message.APPLY_EDITS_SESSION_UNKNOWN.send(sender, this.id, commandLabel); + return; + } + } else if (remoteSession.isCompleted()) { + // session has been completed already + if (!ignoreSessionWarning) { + Message.APPLY_EDITS_SESSION_APPLIED_ALREADY.send(sender, this.id, commandLabel); + return; } } - Session session = new Session(plugin, sender); + ChangeApplier changeApplier = new ChangeApplier(plugin, sender, editorSession, remoteSession); boolean work = false; if (this.payload.has("changes")) { JsonArray changes = this.payload.get("changes").getAsJsonArray(); for (JsonElement change : changes) { - if (session.applyChange(change.getAsJsonObject())) { + if (changeApplier.applyChange(change.getAsJsonObject())) { work = true; } } @@ -128,7 +118,7 @@ public class WebEditorResponse { if (this.payload.has("userDeletions")) { JsonArray userDeletions = this.payload.get("userDeletions").getAsJsonArray(); for (JsonElement userDeletion : userDeletions) { - if (session.applyUserDelete(userDeletion)) { + if (changeApplier.applyUserDelete(userDeletion)) { work = true; } } @@ -136,7 +126,7 @@ public class WebEditorResponse { if (this.payload.has("groupDeletions")) { JsonArray groupDeletions = this.payload.get("groupDeletions").getAsJsonArray(); for (JsonElement groupDeletion : groupDeletions) { - if (session.applyGroupDelete(groupDeletion)) { + if (changeApplier.applyGroupDelete(groupDeletion)) { work = true; } } @@ -144,12 +134,16 @@ public class WebEditorResponse { if (this.payload.has("trackDeletions")) { JsonArray trackDeletions = this.payload.get("trackDeletions").getAsJsonArray(); for (JsonElement trackDeletion : trackDeletions) { - if (session.applyTrackDelete(trackDeletion)) { + if (changeApplier.applyTrackDelete(trackDeletion)) { work = true; } } } + if (remoteSession != null) { + remoteSession.complete(); + } + if (!work) { Message.APPLY_EDITS_TARGET_NO_CHANGES_PRESENT.send(sender); } @@ -158,13 +152,17 @@ public class WebEditorResponse { /** * Represents the application of a given editor session on this platform. */ - private static class Session { + private static class ChangeApplier { private final LuckPermsPlugin plugin; private final Sender sender; + private final WebEditorSession session; + private final RemoteSession remoteSession; - Session(LuckPermsPlugin plugin, Sender sender) { + ChangeApplier(LuckPermsPlugin plugin, Sender sender, WebEditorSession session, RemoteSession remoteSession) { this.plugin = plugin; this.sender = sender; + this.session = session; + this.remoteSession = remoteSession; } private boolean applyChange(JsonObject changeInfo) { @@ -202,6 +200,9 @@ public class WebEditorResponse { holder = this.plugin.getStorage().loadGroup(id).join().orElse(null); if (holder == null) { holder = this.plugin.getStorage().createAndLoadGroup(id, CreationCause.WEB_EDITOR).join(); + if (this.session != null) { + this.session.includeCreatedGroup((Group) holder); + } } } @@ -211,7 +212,7 @@ public class WebEditorResponse { } Set nodes = NodeJsonSerializer.deserializeNodes(changeInfo.getAsJsonArray("nodes")); - MutateResult res = holder.setNodes(DataType.NORMAL, nodes, true); + Difference res = applyNodeChanges(holder, nodes); if (res.isEmpty()) { return false; @@ -233,22 +234,51 @@ public class WebEditorResponse { Message.APPLY_EDITS_SUCCESS.send(this.sender, type, holder.getFormattedDisplayName()); Message.APPLY_EDITS_SUCCESS_SUMMARY.send(this.sender, added.size(), removed.size()); + for (Node n : added) { Message.APPLY_EDITS_DIFF_ADDED.send(this.sender, n); } for (Node n : removed) { Message.APPLY_EDITS_DIFF_REMOVED.send(this.sender, n); } + StorageAssistant.save(holder, this.sender, this.plugin); return true; } + private Difference applyNodeChanges(PermissionHolder holder, Set nodes) { + if (this.remoteSession != null) { + + WebEditorRequest request = this.remoteSession.request(); + if (request != null) { + + List nodesBefore = request.getHolders().get(holder.getIdentifier()); + if (nodesBefore != null) { + + // if the initial data sent to the remote session is still known + // use that to calculate a diff of the changes made to avoid overriding + // modified/added/removed nodes since the editor session was created + Difference diff = new Difference<>(); + diff.recordChanges(Difference.ChangeType.REMOVE, nodesBefore); + diff.recordChanges(Difference.ChangeType.ADD, nodes); + + return holder.setNodes(DataType.NORMAL, diff, true); + } + } + } + + return holder.setNodes(DataType.NORMAL, nodes, true); + } + private boolean applyTrackChange(JsonObject changeInfo) { String id = changeInfo.get("id").getAsString(); Track track = this.plugin.getStorage().loadTrack(id).join().orElse(null); if (track == null) { track = this.plugin.getStorage().createAndLoadTrack(id, CreationCause.WEB_EDITOR).join(); + if (this.session != null) { + this.session.includeCreatedTrack(track); + } } if (ArgumentPermissions.checkModifyPerms(this.plugin, this.sender, CommandPermission.APPLY_EDITS, track)) { @@ -264,32 +294,33 @@ public class WebEditorResponse { return false; } - Set diffAdded = getAdded(before, after); - Set diffRemoved = getRemoved(before, after); + Difference diff = new Difference<>(); + diff.recordChanges(Difference.ChangeType.REMOVE, before); + diff.recordChanges(Difference.ChangeType.ADD, after); - int additions = diffAdded.size(); - int deletions = diffRemoved.size(); + Set added = diff.getAdded(); + Set removed = diff.getRemoved(); track.setGroups(after); - if (hasBeenReordered(before, after, diffAdded, diffRemoved)) { + if (hasBeenReordered(before, after, added, removed)) { LoggedAction.build().source(this.sender).target(track) .description("webeditor", "reorder", after) .build().submit(this.plugin, this.sender); } - for (String n : diffAdded) { + for (String n : added) { LoggedAction.build().source(this.sender).target(track) .description("webeditor", "add", n) .build().submit(this.plugin, this.sender); } - for (String n : diffRemoved) { + for (String n : removed) { LoggedAction.build().source(this.sender).target(track) .description("webeditor", "remove", n) .build().submit(this.plugin, this.sender); } Message.APPLY_EDITS_SUCCESS.send(this.sender, "track", Component.text(track.getName())); - Message.APPLY_EDITS_SUCCESS_SUMMARY.send(this.sender, additions, deletions); + Message.APPLY_EDITS_SUCCESS_SUMMARY.send(this.sender, added.size(), removed.size()); Message.APPLY_EDITS_TRACK_BEFORE.send(this.sender, before); Message.APPLY_EDITS_TRACK_AFTER.send(this.sender, after); @@ -339,6 +370,10 @@ public class WebEditorResponse { .description("webeditor", "delete") .build().submit(this.plugin, this.sender); + if (this.session != null) { + this.session.excludeDeletedUser(user); + } + return true; } @@ -374,6 +409,10 @@ public class WebEditorResponse { .description("webeditor", "delete") .build().submit(this.plugin, this.sender); + if (this.session != null) { + this.session.excludeDeletedGroup(group); + } + return true; } @@ -404,21 +443,13 @@ public class WebEditorResponse { .description("webeditor", "delete") .build().submit(this.plugin, this.sender); + if (this.session != null) { + this.session.excludeDeletedTrack(track); + } + return true; } - private static Set getAdded(Collection before, Collection after) { - Set added = new LinkedHashSet<>(after); - added.removeAll(before); - return added; - } - - private static Set getRemoved(Collection before, Collection after) { - Set removed = new LinkedHashSet<>(before); - removed.removeAll(after); - return removed; - } - private static boolean hasBeenReordered(List before, List after, Collection diffAdded, Collection diffRemoved) { after = new ArrayList<>(after); before = new ArrayList<>(before); diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorSession.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorSession.java new file mode 100644 index 000000000..cb29b1bbd --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorSession.java @@ -0,0 +1,206 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor; + +import me.lucko.luckperms.common.config.ConfigKeys; +import me.lucko.luckperms.common.http.AbstractHttpClient; +import me.lucko.luckperms.common.http.UnsuccessfulRequestException; +import me.lucko.luckperms.common.locale.Message; +import me.lucko.luckperms.common.model.Group; +import me.lucko.luckperms.common.model.PermissionHolder; +import me.lucko.luckperms.common.model.PermissionHolderIdentifier; +import me.lucko.luckperms.common.model.Track; +import me.lucko.luckperms.common.model.User; +import me.lucko.luckperms.common.plugin.LuckPermsPlugin; +import me.lucko.luckperms.common.sender.Sender; +import me.lucko.luckperms.common.webeditor.socket.WebEditorSocket; + +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * Encapsulates a session with the web editor. + * + *

A session is tied to a specific user, and can comprise of multiple requests to and + * responses from the web editor.

+ */ +public class WebEditorSession { + + public static void createAndOpen(List holders, List tracks, Sender sender, String cmdLabel, LuckPermsPlugin plugin) { + WebEditorRequest initialRequest = WebEditorRequest.generate(holders, tracks, sender, cmdLabel, plugin); + WebEditorSession session = new WebEditorSession(initialRequest, plugin, sender, cmdLabel); + session.open(); + } + + private WebEditorRequest initialRequest; + + private final LuckPermsPlugin plugin; + private final Sender sender; + private final String cmdLabel; + + private final Set holders; + private final Set tracks; + + private WebEditorSocket socket = null; + + public WebEditorSession(WebEditorRequest initialRequest, LuckPermsPlugin plugin, Sender sender, String cmdLabel) { + this.initialRequest = initialRequest; + this.plugin = plugin; + this.sender = sender; + this.cmdLabel = cmdLabel; + + this.holders = new HashSet<>(initialRequest.getHolders().keySet()); + this.tracks = new HashSet<>(initialRequest.getTracks().keySet()); + } + + public void open() { + createSocket(); + createInitialSession(); + } + + private void createSocket() { + try { + // create and connect to a socket + WebEditorSocket socket = new WebEditorSocket(this.plugin, this.sender, this); + socket.initialize(this.plugin.getBytesocks()); + socket.waitForConnect(5, TimeUnit.SECONDS); + + this.socket = socket; + this.plugin.getWebEditorStore().sockets().putSocket(this.sender, this.socket); + } catch (Exception e) { + if (e instanceof UnsuccessfulRequestException && ((UnsuccessfulRequestException) e).getResponse().code() == 502) { + // 502 - bad gateway, probably means the socket service is offline + // that's ok, no need to send a warning + return; + } + + this.plugin.getLogger().warn("Unable to establish socket connection", e); + } + } + + private void createInitialSession() { + Objects.requireNonNull(this.initialRequest); + + WebEditorRequest request = this.initialRequest; + this.initialRequest = null; + + if (this.socket != null) { + this.socket.appendDetailToRequest(request); + } + + String id = uploadRequestData(request); + if (id == null) { + return; + } + + // form a url for the editor + String url = this.plugin.getConfiguration().get(ConfigKeys.WEB_EDITOR_URL_PATTERN) + id; + Message.EDITOR_URL.send(this.sender, url); + + // schedule socket close + if (this.socket != null) { + this.socket.scheduleCleanupIfUnused(); + } + } + + public void includeCreatedGroup(Group group) { + this.holders.add(group.getIdentifier()); + } + + public void includeCreatedTrack(Track track) { + this.tracks.add(track.getName()); + } + + public void excludeDeletedUser(User user) { + this.holders.remove(user.getIdentifier()); + } + + public void excludeDeletedGroup(Group group) { + this.holders.remove(group.getIdentifier()); + } + + public void excludeDeletedTrack(Track track) { + this.tracks.remove(track.getName()); + } + + public String createFollowUpSession() { + List holders = this.holders.stream() + .map(id -> { + switch (id.getType()) { + case PermissionHolderIdentifier.USER_TYPE: + return this.plugin.getStorage().loadUser(UUID.fromString(id.getName()), null); + case PermissionHolderIdentifier.GROUP_TYPE: + return this.plugin.getStorage().loadGroup(id.getName()).thenApply(o -> o.orElse(null)); + default: + return null; + } + }) + .filter(Objects::nonNull) + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + List tracks = this.tracks.stream() + .map(id -> this.plugin.getStorage().loadTrack(id).thenApply(o -> o.orElse(null))) + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + return uploadRequestData(WebEditorRequest.generate(holders, tracks, this.sender, this.cmdLabel, this.plugin)); + } + + public String getCommandLabel() { + return this.cmdLabel; + } + + private String uploadRequestData(WebEditorRequest request) { + byte[] requestBuf = request.encode(); + + String pasteId; + try { + pasteId = this.plugin.getBytebin().postContent(requestBuf, AbstractHttpClient.JSON_TYPE, "editor").key(); + } catch (UnsuccessfulRequestException e) { + Message.EDITOR_HTTP_REQUEST_FAILURE.send(this.sender, e.getResponse().code(), e.getResponse().message()); + return null; + } catch (IOException e) { + new RuntimeException("Error uploading data to bytebin", e).printStackTrace(); + Message.EDITOR_HTTP_UNKNOWN_FAILURE.send(this.sender); + return null; + } + + this.plugin.getWebEditorStore().sessions().addNewSession(pasteId, request); + return pasteId; + } + + +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/CryptographyUtils.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/CryptographyUtils.java new file mode 100644 index 000000000..658c3b4da --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/CryptographyUtils.java @@ -0,0 +1,118 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.socket; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +/** + * Utilities for public/private key crypto used by the web editor socket connection. + */ +public final class CryptographyUtils { + private CryptographyUtils() {} + + /** + * Parse a public key from the given string. + * + * @param base64String a base64 string encoding the public key + * @return the parsed public key + * @throws IllegalArgumentException if the input was invalid + */ + public static PublicKey parsePublicKey(String base64String) throws IllegalArgumentException { + try { + byte[] bytes = Base64.getDecoder().decode(base64String); + X509EncodedKeySpec spec = new X509EncodedKeySpec(bytes); + KeyFactory rsa = KeyFactory.getInstance("RSA"); + return rsa.generatePublic(spec); + } catch (Exception e) { + throw new IllegalArgumentException("Exception parsing public key", e); + } + } + + /** + * Generate a public/private key pair. + * + * @return the generated key pair + */ + public static KeyPair generateKeyPair() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(4096); + return generator.generateKeyPair(); + } catch (Exception e) { + throw new RuntimeException("Exception generating keypair", e); + } + } + + /** + * Signs {@code msg} using the given {@link PrivateKey}. + * + * @param privateKey the private key to sign with + * @param msg the message + * @return a base64 string containing the signature + */ + public static String sign(PrivateKey privateKey, String msg) { + try { + Signature sign = Signature.getInstance("SHA256withRSA"); + sign.initSign(privateKey); + sign.update(msg.getBytes(StandardCharsets.UTF_8)); + + return Base64.getEncoder().encodeToString(sign.sign()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * Verify that the given base64 encoded signature matches + * the given message and {@link PublicKey}. + * + * @param publicKey the public key that the message was supposedly signed with + * @param msg the message + * @param signatureBase64 the provided signature + * @return true if the signature is ok + */ + public static boolean verify(PublicKey publicKey, String msg, String signatureBase64) { + try { + Signature sign = Signature.getInstance("SHA256withRSA"); + sign.initVerify(publicKey); + sign.update(msg.getBytes(StandardCharsets.UTF_8)); + + byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64); + return sign.verify(signatureBytes); + } catch (Exception e) { + return false; + } + } + +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/SocketMessageType.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/SocketMessageType.java new file mode 100644 index 000000000..2b116ad09 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/SocketMessageType.java @@ -0,0 +1,78 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.socket; + +import me.lucko.luckperms.common.util.ImmutableCollectors; +import me.lucko.luckperms.common.util.gson.JObject; + +import java.util.Arrays; +import java.util.Map; +import java.util.function.Function; + +public enum SocketMessageType { + + /** Sent when the editor first says "hello" over the channel. (editor -> plugin) */ + HELLO("hello"), + + /** Sent when the plugin replies to the editors "hello" message. (plugin -> editor) */ + HELLO_REPLY("hello-reply"), + + /** Sent by the editor to confirm that a connection has been established. (editor -> plugin) */ + CONNECTED("connected"), + + /** Sent by the editor to request that the plugin applies a change. (editor -> plugin) */ + CHANGE_REQUEST("change-request"), + + /** Sent by the plugin to confirm that the changes sent by the editor have been accepted or applied. (plugin -> editor) */ + CHANGE_RESPONSE("change-response"), + + /** Ping message to keep the socket alive. (editor -> plugin) */ + PING("ping"), + + /** Ping response. (plugin -> editor) */ + PONG("pong"); + + public final String id; + + SocketMessageType(String id) { + this.id = id; + } + + public JObject builder() { + return new JObject().add("type", this.id); + } + + private static final Map LOOKUP = Arrays.stream(SocketMessageType.values()) + .collect(ImmutableCollectors.toMap(m -> m.id, Function.identity())); + + public static SocketMessageType getById(String id) { + SocketMessageType type = LOOKUP.get(id); + if (type == null) { + throw new IllegalArgumentException(id); + } + return type; + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/WebEditorSocket.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/WebEditorSocket.java new file mode 100644 index 000000000..aaed889e4 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/WebEditorSocket.java @@ -0,0 +1,258 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.socket; + +import com.google.gson.JsonObject; + +import me.lucko.luckperms.common.http.BytesocksClient; +import me.lucko.luckperms.common.http.UnsuccessfulRequestException; +import me.lucko.luckperms.common.plugin.LuckPermsPlugin; +import me.lucko.luckperms.common.plugin.scheduler.SchedulerTask; +import me.lucko.luckperms.common.sender.Sender; +import me.lucko.luckperms.common.util.gson.GsonProvider; +import me.lucko.luckperms.common.util.gson.JObject; +import me.lucko.luckperms.common.webeditor.WebEditorRequest; +import me.lucko.luckperms.common.webeditor.WebEditorSession; +import me.lucko.luckperms.common.webeditor.socket.listener.WebEditorSocketListener; + +import java.io.IOException; +import java.security.KeyPair; +import java.security.PublicKey; +import java.util.Base64; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class WebEditorSocket { + + private static final int PROTOCOL_VERSION = 1; + + /** The plugin */ + private final LuckPermsPlugin plugin; + /** The sender who created the editor session */ + private final Sender sender; + /** The web editor session */ + private final WebEditorSession session; + /** The socket listener that handles incoming messages */ + private final WebEditorSocketListener listener; + + /** The websocket backing the connection */ + private BytesocksClient.Socket socket; + /** The public and private keys used to sign messages sent by the plugin */ + private KeyPair localKeys; + /** A task to check if the socket is still active */ + private SchedulerTask keepaliveTask; + /** The public key used by the editor to sign messages */ + private PublicKey remotePublicKey; + /** If the connection is closed */ + private boolean closed = false; + + public WebEditorSocket(LuckPermsPlugin plugin, Sender sender, WebEditorSession session) { + this.plugin = plugin; + this.sender = sender; + this.session = session; + this.listener = new WebEditorSocketListener(this); + } + + /** + * Initializes the socket connection. + * + * @param client the bytesocks client to connect to + * @throws UnsuccessfulRequestException if the request fails + * @throws IOException if an i/o error occurs + */ + public void initialize(BytesocksClient client) throws UnsuccessfulRequestException, IOException { + this.socket = client.createSocket(this.listener); + this.localKeys = CryptographyUtils.generateKeyPair(); + } + + /** + * Waits the specified amount of time for the socket to connect, + * before throwing an exception if a timeout occurs. + * + * @param timeout the timeout + * @param unit the timeout unit + */ + public void waitForConnect(long timeout, TimeUnit unit) { + try { + this.listener.connectFuture().get(timeout, unit); + } catch (ExecutionException | TimeoutException | InterruptedException e) { + throw new RuntimeException("Timed out waiting to socket to connect", e); + } + } + + /** + * Adds detail about the socket channel and the plugin public key to + * the editor request payload that gets sent via bytebin to the viewer. + * + * @param request the request + */ + public void appendDetailToRequest(WebEditorRequest request) { + String channelId = this.socket.channelId(); + String publicKey = Base64.getEncoder().encodeToString(this.localKeys.getPublic().getEncoded()); + + JsonObject socket = new JsonObject(); + socket.addProperty("protocolVersion", PROTOCOL_VERSION); + socket.addProperty("channelId", channelId); + socket.addProperty("publicKey", publicKey); + + JsonObject payload = request.getPayload(); + payload.add("socket", socket); + } + + /** + * Send a message to the socket. + * + *

The message will be encoded as JSON and + * signed using the public public key.

+ * + * @param msg the message + */ + public void send(JsonObject msg) { + String encoded = GsonProvider.normal().toJson(msg); + String signature = CryptographyUtils.sign(this.localKeys.getPrivate(), encoded); + + JsonObject frame = new JObject() + .add("msg", encoded) + .add("signature", signature) + .toJson(); + + this.socket.socket().send(GsonProvider.normal().toJson(frame)); + } + + public boolean trustConnection(String nonce) { + if (this.listener.shouldIgnoreMessages()) { + return false; + } + + if (this.remotePublicKey != null) { + return false; + } + + PublicKey publicKey = this.listener.helloHandler().getAttemptedConnection(nonce); + if (publicKey == null) { + return false; + } + + this.remotePublicKey = publicKey; + + // save the key in the keystore + this.plugin.getWebEditorStore().keystore().trust(this.sender, this.remotePublicKey.getEncoded()); + + // send a reply back to the editor to say that it is now trusted + send(SocketMessageType.HELLO_REPLY.builder() + .add("nonce", nonce) + .add("state", "trusted") + .toJson() + ); + return true; + } + + public void scheduleCleanupIfUnused() { + this.plugin.getBootstrap().getScheduler().asyncLater(this::afterOpenFor1Minute, 1, TimeUnit.MINUTES); + } + + private void afterOpenFor1Minute() { + if (this.closed) { + return; + } + + if (this.remotePublicKey == null && !this.listener.helloHandler().hasAttemptedConnection()) { + // If the editor hasn't made an initial connection after 1 minute, + // then close + stop listening to the socket. + closeSocket(); + } else { + // Otherwise, setup a keepalive monitoring task + this.keepaliveTask = this.plugin.getBootstrap().getScheduler().asyncRepeating(this::keepalive, 10, TimeUnit.SECONDS); + } + } + + /** + * The keepalive tasks checks to see when the last ping from the editor was. If the editor + * hasn't sent anything for 1 minute, then close the connection + */ + private void keepalive() { + if (System.currentTimeMillis() - this.listener.pingHandler().getLastPing() > TimeUnit.MINUTES.toMillis(1)) { + cancelKeepalive(); + closeSocket(); + } + } + + public void close() { + try { + send(SocketMessageType.PONG.builder() + .add("ok", false) + .toJson() + ); + } catch (Exception e) { + // ignore + } + + cancelKeepalive(); + closeSocket(); + } + + private void closeSocket() { + this.socket.socket().close(1000, "Normal"); + this.closed = true; + } + + private void cancelKeepalive() { + if (this.keepaliveTask != null) { + this.keepaliveTask.cancel(); + this.keepaliveTask = null; + } + } + + public LuckPermsPlugin getPlugin() { + return this.plugin; + } + + public Sender getSender() { + return this.sender; + } + + public WebEditorSession getSession() { + return this.session; + } + + public BytesocksClient.Socket getSocket() { + return this.socket; + } + + public PublicKey getRemotePublicKey() { + return this.remotePublicKey; + } + + public void setRemotePublicKey(PublicKey remotePublicKey) { + this.remotePublicKey = remotePublicKey; + } + + public boolean isClosed() { + return this.closed; + } + +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/SessionState.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/Handler.java similarity index 75% rename from common/src/main/java/me/lucko/luckperms/common/webeditor/SessionState.java rename to common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/Handler.java index a99574357..e1170f214 100644 --- a/common/src/main/java/me/lucko/luckperms/common/webeditor/SessionState.java +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/Handler.java @@ -23,26 +23,15 @@ * SOFTWARE. */ -package me.lucko.luckperms.common.webeditor; +package me.lucko.luckperms.common.webeditor.socket.listener; + +import com.google.gson.JsonObject; /** - * Represents the state of a web editor session + * A handler for a given type of message. */ -enum SessionState { +public interface Handler { - /** - * The session is not known to this server. - */ - NOT_KNOWN, - - /** - * The session is in progress - it has been created, but updates have not been applied. - */ - IN_PROGRESS, - - /** - * The session is complete - updates have been applied. - */ - COMPLETED + void handle(JsonObject msg); } diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerChangeRequest.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerChangeRequest.java new file mode 100644 index 000000000..9ccf5fb50 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerChangeRequest.java @@ -0,0 +1,105 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.socket.listener; + +import com.google.gson.JsonObject; + +import me.lucko.luckperms.common.command.access.CommandPermission; +import me.lucko.luckperms.common.http.UnsuccessfulRequestException; +import me.lucko.luckperms.common.locale.Message; +import me.lucko.luckperms.common.webeditor.WebEditorResponse; +import me.lucko.luckperms.common.webeditor.socket.SocketMessageType; +import me.lucko.luckperms.common.webeditor.socket.WebEditorSocket; + +import java.io.IOException; +import java.util.Objects; + +/** + * Handler for {@link SocketMessageType#CHANGE_REQUEST} + */ +public class HandlerChangeRequest implements Handler { + + /** The change has been accepted, and the plugin will now apply it. */ + private static final String STATE_ACCEPTED = "accepted"; + /** The change has been applied. */ + private static final String STATE_APPLIED = "applied"; + + /** The socket */ + private final WebEditorSocket socket; + + public HandlerChangeRequest(WebEditorSocket socket) { + this.socket = socket; + } + + @Override + public void handle(JsonObject msg) { + if (!this.socket.getSender().hasPermission(CommandPermission.APPLY_EDITS)) { + throw new IllegalStateException("Sender does not have applyedits permission"); + } + + // get the bytebin code containing the editor data + String code = msg.get("code").getAsString(); + if (code == null || code.isEmpty()) { + throw new IllegalArgumentException("Invalid code"); + } + + // send "change-accepted" response + this.socket.getPlugin().getBootstrap().getScheduler().executeAsync(() -> + this.socket.send(SocketMessageType.CHANGE_RESPONSE.builder() + .add("state", STATE_ACCEPTED) + .toJson() + ) + ); + + // download data from bytebin + JsonObject data; + try { + data = this.socket.getPlugin().getBytebin().getJsonContent(code).getAsJsonObject(); + Objects.requireNonNull(data); + } catch (UnsuccessfulRequestException | IOException e) { + throw new RuntimeException("Error reading data", e); + } + + // apply changes + Message.EDITOR_SOCKET_CHANGES_RECEIVED.send(this.socket.getSender()); + new WebEditorResponse(code, data).apply( + this.socket.getPlugin(), + this.socket.getSender(), + this.socket.getSession(), + "lp", + false + ); + + // create a new session + String newSessionCode = this.socket.getSession().createFollowUpSession(); + this.socket.send(SocketMessageType.CHANGE_RESPONSE.builder() + .add("state", STATE_APPLIED) + .add("newSessionCode", newSessionCode) + .toJson() + ); + } + +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerConnected.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerConnected.java new file mode 100644 index 000000000..457d62913 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerConnected.java @@ -0,0 +1,50 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.socket.listener; + +import com.google.gson.JsonObject; + +import me.lucko.luckperms.common.locale.Message; +import me.lucko.luckperms.common.webeditor.socket.SocketMessageType; +import me.lucko.luckperms.common.webeditor.socket.WebEditorSocket; + +/** + * Handler for {@link SocketMessageType#CONNECTED} + */ +public class HandlerConnected implements Handler { + + /** The socket */ + private final WebEditorSocket socket; + + public HandlerConnected(WebEditorSocket socket) { + this.socket = socket; + } + + @Override + public void handle(JsonObject msg) { + Message.EDITOR_SOCKET_CONNECTED.send(this.socket.getSender()); + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerHello.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerHello.java new file mode 100644 index 000000000..a5c239004 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerHello.java @@ -0,0 +1,130 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.socket.listener; + +import com.google.gson.JsonObject; + +import me.lucko.luckperms.common.locale.Message; +import me.lucko.luckperms.common.webeditor.socket.CryptographyUtils; +import me.lucko.luckperms.common.webeditor.socket.SocketMessageType; +import me.lucko.luckperms.common.webeditor.socket.WebEditorSocket; +import me.lucko.luckperms.common.webeditor.store.RemoteSession; + +import java.security.PublicKey; +import java.util.HashMap; +import java.util.Map; + +/** + * Handler for {@link SocketMessageType#HELLO} + */ +public class HandlerHello implements Handler { + + /** The session is accepted, the editor public key is already known so no further action is needed */ + private static final String STATE_ACCEPTED = "accepted"; + /** The session is accepted, but the user needs to confirm in-game before any changes will be accepted. */ + private static final String STATE_UNTRUSTED = "untrusted"; + /** A session has already been established with a different identity */ + private static final String STATE_REJECTED = "rejected"; + /** The remote editor session is based off session data which has already been applied */ + private static final String STATE_INVALID = "invalid"; + + /** The socket */ + private final WebEditorSocket socket; + + /** A list of attempted connections (connections that have been attempted with an untrusted public key) */ + private final Map attemptedConnections = new HashMap<>(); + + public HandlerHello(WebEditorSocket socket) { + this.socket = socket; + } + + public PublicKey getAttemptedConnection(String nonce) { + return this.attemptedConnections.get(nonce); + } + + public boolean hasAttemptedConnection() { + return !this.attemptedConnections.isEmpty(); + } + + @Override + public void handle(JsonObject msg) { + String nonce = getStringOrThrow(msg, "nonce"); + String sessionId = getStringOrThrow(msg, "sessionId"); + String browser = msg.get("browser").getAsString(); + PublicKey remotePublicKey = CryptographyUtils.parsePublicKey(msg.get("publicKey").getAsString()); + + // check if the public keys are the same + // (this allows the same editor to re-connect, but prevents new connections) + if (this.socket.getRemotePublicKey() != null && !this.socket.getRemotePublicKey().equals(remotePublicKey)) { + sendReply(nonce, STATE_REJECTED); + return; + } + + // check if session is valid + RemoteSession session = this.socket.getPlugin().getWebEditorStore().sessions().getSession(sessionId); + if (session == null || session.isCompleted()) { + sendReply(nonce, STATE_INVALID); + return; + } + + // check if the public key is trusted + if (!this.socket.getPlugin().getWebEditorStore().keystore().isTrusted(this.socket.getSender(), remotePublicKey.getEncoded())) { + sendReply(nonce, STATE_UNTRUSTED); + + // ask the user if they want to trust the connection + Message.EDITOR_SOCKET_UNTRUSTED.send(this.socket.getSender(), nonce, browser, this.socket.getSession().getCommandLabel(), this.socket.getSender().isConsole()); + this.attemptedConnections.put(nonce, remotePublicKey); + return; + } + + boolean reconnected = this.socket.getRemotePublicKey() != null; + this.socket.setRemotePublicKey(remotePublicKey); + + sendReply(nonce, STATE_ACCEPTED); + + if (reconnected) { + Message.EDITOR_SOCKET_RECONNECTED.send(this.socket.getSender()); + } else { + Message.EDITOR_SOCKET_CONNECTED.send(this.socket.getSender()); + } + } + + private void sendReply(String nonce, String state) { + this.socket.send(SocketMessageType.HELLO_REPLY.builder() + .add("nonce", nonce) + .add("state", state) + .toJson() + ); + } + + private static String getStringOrThrow(JsonObject msg, String key) { + String val = msg.get(key).getAsString(); + if (val == null || val.isEmpty()) { + throw new IllegalStateException("missing " + key); + } + return val; + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerPing.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerPing.java new file mode 100644 index 000000000..68fe75549 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/HandlerPing.java @@ -0,0 +1,60 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.socket.listener; + +import com.google.gson.JsonObject; + +import me.lucko.luckperms.common.webeditor.socket.SocketMessageType; +import me.lucko.luckperms.common.webeditor.socket.WebEditorSocket; + +/** + * Handler for {@link SocketMessageType#PING} + */ +public class HandlerPing implements Handler { + + /** The socket */ + private final WebEditorSocket socket; + + /** The time a ping was last received */ + private long lastPing = 0; + + public HandlerPing(WebEditorSocket socket) { + this.socket = socket; + } + + public long getLastPing() { + return this.lastPing; + } + + @Override + public void handle(JsonObject msg) { + this.lastPing = System.currentTimeMillis(); + this.socket.send(SocketMessageType.PONG.builder() + .add("ok", true) + .toJson() + ); + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/WebEditorSocketListener.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/WebEditorSocketListener.java new file mode 100644 index 000000000..e50676ed1 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/socket/listener/WebEditorSocketListener.java @@ -0,0 +1,181 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.socket.listener; + +import com.google.gson.JsonObject; + +import me.lucko.luckperms.common.util.gson.GsonProvider; +import me.lucko.luckperms.common.webeditor.socket.CryptographyUtils; +import me.lucko.luckperms.common.webeditor.socket.SocketMessageType; +import me.lucko.luckperms.common.webeditor.socket.WebEditorSocket; + +import org.checkerframework.checker.nullness.qual.NonNull; + +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; + +import java.io.EOFException; +import java.security.PublicKey; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.ReentrantLock; + +public class WebEditorSocketListener extends WebSocketListener { + + /** The socket */ + private final WebEditorSocket socket; + + // Individual handlers for each message type + private final HandlerHello helloHandler; + private final HandlerConnected connectedHandler; + private final HandlerPing pingHandler; + private final HandlerChangeRequest changeRequestHandler; + + /** A future that will complete when the connection is established successfully */ + private final CompletableFuture connectFuture = new CompletableFuture<>(); + + /** Message receive lock */ + private final ReentrantLock lock = new ReentrantLock(); + + public WebEditorSocketListener(WebEditorSocket socket) { + this.socket = socket; + this.helloHandler = new HandlerHello(socket); + this.connectedHandler = new HandlerConnected(socket); + this.pingHandler = new HandlerPing(socket); + this.changeRequestHandler = new HandlerChangeRequest(socket); + } + + @Override + public void onOpen(@NonNull WebSocket webSocket, @NonNull Response response) { + this.connectFuture.complete(null); + } + + @Override + public void onFailure(@NonNull WebSocket webSocket, @NonNull Throwable e, Response response) { + if (e instanceof EOFException) { + return; // ignore + } + this.socket.getPlugin().getLogger().warn("Exception occurred in web socket", e); + } + + @Override + public void onMessage(@NonNull WebSocket webSocket, @NonNull String msg) { + this.socket.getPlugin().getBootstrap().getScheduler().executeAsync(() -> { + this.lock.lock(); + try { + if (shouldIgnoreMessages()) { + return; + } + + handleMessageFrame(msg); + } catch (Exception e) { + this.socket.getPlugin().getLogger().warn("Exception occurred handling message from socket", e); + } finally { + this.lock.unlock(); + } + }); + } + + /** + * Checks if incoming messages should be ignored. + * + * @return true if messages should be ignored + */ + public boolean shouldIgnoreMessages() { + if (this.socket.isClosed()) { + return true; + } + + if (!this.socket.getSender().isValid()) { + this.socket.close(); + return true; + } + + return false; + } + + private void handleMessageFrame(String stringMsg) { + JsonObject frame = GsonProvider.parser().parse(stringMsg).getAsJsonObject(); + + String innerMsg = frame.get("msg").getAsString(); + String signature = frame.get("signature").getAsString(); + + if (innerMsg == null || innerMsg.isEmpty() || signature == null || signature.isEmpty()) { + throw new IllegalArgumentException("Incomplete message"); + } + + // check signature to ensure the message is from the connected editor + PublicKey remotePublicKey = this.socket.getRemotePublicKey(); + boolean verified = remotePublicKey == null || CryptographyUtils.verify(remotePublicKey, innerMsg, signature); + + // parse the inner message + JsonObject msg = GsonProvider.parser().parse(innerMsg).getAsJsonObject(); + SocketMessageType type = SocketMessageType.getById(msg.get("type").getAsString()); + + if (type == SocketMessageType.HELLO) { + this.helloHandler.handle(msg); + return; + } + + if (!verified) { + throw new IllegalStateException("Signature not accepted"); + } + + switch (type) { + case CHANGE_REQUEST: + this.changeRequestHandler.handle(msg); + break; + case CONNECTED: + this.connectedHandler.handle(msg); + break; + case PING: + this.pingHandler.handle(msg); + break; + default: + throw new IllegalStateException("Invalid message type: " + type); + } + } + + public CompletableFuture connectFuture() { + return this.connectFuture; + } + + public HandlerHello helloHandler() { + return this.helloHandler; + } + + public HandlerConnected connectedHandler() { + return this.connectedHandler; + } + + public HandlerPing pingHandler() { + return this.pingHandler; + } + + public HandlerChangeRequest changeRequestHandler() { + return this.changeRequestHandler; + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/store/RemoteSession.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/store/RemoteSession.java new file mode 100644 index 000000000..1759df8cc --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/store/RemoteSession.java @@ -0,0 +1,51 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.store; + +import me.lucko.luckperms.common.webeditor.WebEditorRequest; + +public final class RemoteSession { + private WebEditorRequest request; + private boolean completed; + + public RemoteSession(WebEditorRequest request) { + this.request = request; + this.completed = false; + } + + public WebEditorRequest request() { + return this.request; + } + + public boolean isCompleted() { + return this.completed; + } + + public void complete() { + this.completed = true; + this.request = null; + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorKeystore.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorKeystore.java new file mode 100644 index 000000000..ab64c4ae6 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorKeystore.java @@ -0,0 +1,196 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.store; + +import me.lucko.luckperms.common.context.ImmutableContextSetImpl; +import me.lucko.luckperms.common.model.User; +import me.lucko.luckperms.common.node.types.Meta; +import me.lucko.luckperms.common.query.QueryOptionsImpl; +import me.lucko.luckperms.common.sender.Sender; +import me.lucko.luckperms.common.util.gson.GsonProvider; +import me.lucko.luckperms.common.verbose.event.CheckOrigin; + +import net.luckperms.api.model.data.DataType; +import net.luckperms.api.node.NodeType; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +public final class WebEditorKeystore { + private static final String META_KEY = "lp-editor-key"; + + private final Path consoleKeysPath; + private final Set trustedConsoleKeys; + + public WebEditorKeystore(Path consoleKeysPath) { + this.consoleKeysPath = consoleKeysPath; + this.trustedConsoleKeys = new CopyOnWriteArraySet<>(); + + try { + load(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * Checks if the given public key has been trusted by the sender. + * + * @param sender the sender + * @param publicKey the public key + * @return true if trusted + */ + public boolean isTrusted(Sender sender, byte[] publicKey) { + return isTrusted(sender, hash(publicKey)); + } + + /** + * Checks if the given public key hash has been trusted by the sender. + * + * @param sender the sender + * @param hash the public key hash + * @return true if trusted + */ + public boolean isTrusted(Sender sender, String hash) { + if (sender.isConsole()) { + return isTrustedConsole(hash); + } else { + User user = sender.getPlugin().getUserManager().getIfLoaded(sender.getUniqueId()); + return user != null && isTrusted(user, hash); + } + } + + /** + * Trusts the given public key for the sender. + * + * @param sender the sender + * @param publicKey the public key + */ + public void trust(Sender sender, byte[] publicKey) { + trust(sender, hash(publicKey)); + } + + /** + * Trusts the given public key hash for the sender. + * + * @param sender the sender + * @param hash the public key hash + */ + public void trust(Sender sender, String hash) { + if (sender.isConsole()) { + trustConsole(hash); + } else { + User user = sender.getPlugin().getUserManager().getIfLoaded(sender.getUniqueId()); + if (user != null) { + trust(user, hash); + } + } + } + + // console + + private boolean isTrustedConsole(String hash) { + return this.trustedConsoleKeys.contains(hash); + } + + private void trustConsole(String hash) { + this.trustedConsoleKeys.add(hash); + + try { + save(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + private void load() throws Exception { + if (Files.exists(this.consoleKeysPath)) { + try (BufferedReader reader = Files.newBufferedReader(this.consoleKeysPath, StandardCharsets.UTF_8)) { + KeystoreFile file = GsonProvider.normal().fromJson(reader, KeystoreFile.class); + if (file != null && file.consoleKeys != null) { + this.trustedConsoleKeys.addAll(file.consoleKeys); + } + } + } + } + + private void save() throws Exception { + try (BufferedWriter writer = Files.newBufferedWriter(this.consoleKeysPath, StandardCharsets.UTF_8)) { + KeystoreFile file = new KeystoreFile(); + file.consoleKeys = new ArrayList<>(this.trustedConsoleKeys); + GsonProvider.prettyPrinting().toJson(file, writer); + } + } + + // users + + private boolean isTrusted(User user, String hash) { + String key = user.getCachedData().getMetaData(QueryOptionsImpl.DEFAULT_CONTEXTUAL) + .getMetaValue(META_KEY, CheckOrigin.INTERNAL).result(); + + if (key == null || key.isEmpty()) { + return false; + } + + return hash.equals(key); + } + + private void trust(User user, String hash) { + user.removeIf(DataType.NORMAL, ImmutableContextSetImpl.EMPTY, NodeType.META.predicate(mn -> mn.getMetaKey().equals(META_KEY)), false); + user.setNode(DataType.NORMAL, Meta.builder(META_KEY, hash).build(), false); + + user.getPlugin().getStorage().saveUser(user).join(); + } + + private static String hash(byte[] buf) { + byte[] digest = createDigest().digest(buf); + return Base64.getEncoder().encodeToString(digest); + } + + private static MessageDigest createDigest() { + try { + return MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings({"FieldMayBeFinal", "unused"}) + private static class KeystoreFile { + private String _comment = "This file stores a list of trusted editor public keys"; + private List consoleKeys = null; + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorSessionStore.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorSessionMap.java similarity index 64% rename from common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorSessionStore.java rename to common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorSessionMap.java index be7d897f8..473983b42 100644 --- a/common/src/main/java/me/lucko/luckperms/common/webeditor/WebEditorSessionStore.java +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorSessionMap.java @@ -23,45 +23,35 @@ * SOFTWARE. */ -package me.lucko.luckperms.common.webeditor; +package me.lucko.luckperms.common.webeditor.store; -import org.checkerframework.checker.nullness.qual.NonNull; +import me.lucko.luckperms.common.webeditor.WebEditorRequest; + +import org.checkerframework.checker.nullness.qual.Nullable; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -/** - * Contains a store of known web editor sessions. - */ -public class WebEditorSessionStore { - private final Map sessions = new ConcurrentHashMap<>(); +public final class WebEditorSessionMap { + private final Map sessions = new ConcurrentHashMap<>(); /** * Adds a newly created session to the store. * * @param id the id of the session */ - public void addNewSession(String id) { - this.sessions.put(id, SessionState.IN_PROGRESS); + public void addNewSession(String id, WebEditorRequest request) { + this.sessions.put(id, new RemoteSession(request)); } /** - * Gets the session state for the given session id. + * Gets the session for the given session id. * * @param id the id of the session - * @return the session state + * @return the session */ - public @NonNull SessionState getSessionState(String id) { - return this.sessions.getOrDefault(id, SessionState.NOT_KNOWN); - } - - /** - * Marks a given session as complete. - * - * @param id the id of the session - */ - public void markSessionCompleted(String id) { - this.sessions.put(id, SessionState.COMPLETED); + public @Nullable RemoteSession getSession(String id) { + return this.sessions.get(id); } } diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorSocketMap.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorSocketMap.java new file mode 100644 index 000000000..9e7fb2de7 --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorSocketMap.java @@ -0,0 +1,50 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.store; + +import com.github.benmanes.caffeine.cache.Cache; + +import me.lucko.luckperms.common.sender.Sender; +import me.lucko.luckperms.common.util.CaffeineFactory; +import me.lucko.luckperms.common.webeditor.socket.WebEditorSocket; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public final class WebEditorSocketMap { + private final Cache sockets = CaffeineFactory.newBuilder() + .weakValues() + .expireAfterWrite(5, TimeUnit.MINUTES) + .build(); + + public WebEditorSocket getSocket(Sender sender) { + return this.sockets.getIfPresent(sender.getUniqueId()); + } + + public void putSocket(Sender sender, WebEditorSocket socket) { + this.sockets.put(sender.getUniqueId(), socket); + } +} diff --git a/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorStore.java b/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorStore.java new file mode 100644 index 000000000..82a7bd47a --- /dev/null +++ b/common/src/main/java/me/lucko/luckperms/common/webeditor/store/WebEditorStore.java @@ -0,0 +1,57 @@ +/* + * This file is part of LuckPerms, licensed under the MIT License. + * + * Copyright (c) lucko (Luck) + * Copyright (c) contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package me.lucko.luckperms.common.webeditor.store; + +import me.lucko.luckperms.common.plugin.LuckPermsPlugin; + +/** + * Contains a store of known web editor sessions and provides a lookup function for + * trusted editor public keys. + */ +public class WebEditorStore { + private final WebEditorSessionMap sessions; + private final WebEditorSocketMap sockets; + private final WebEditorKeystore keystore; + + public WebEditorStore(LuckPermsPlugin plugin) { + this.sessions = new WebEditorSessionMap(); + this.sockets = new WebEditorSocketMap(); + this.keystore = new WebEditorKeystore(plugin.getBootstrap().getConfigDirectory().resolve("editor-keystore.json")); + } + + public WebEditorSessionMap sessions() { + return this.sessions; + } + + public WebEditorSocketMap sockets() { + return this.sockets; + } + + public WebEditorKeystore keystore() { + return this.keystore; + } + +} diff --git a/common/src/main/resources/luckperms_en.properties b/common/src/main/resources/luckperms_en.properties index 220e48017..6ce54db35 100644 --- a/common/src/main/resources/luckperms_en.properties +++ b/common/src/main/resources/luckperms_en.properties @@ -91,6 +91,18 @@ luckperms.command.misc.loading.error.track-invalid={0} is not a valid track name luckperms.command.editor.no-match=Unable to open editor, no objects matched the desired type luckperms.command.editor.start=Preparing a new editor session, please wait... luckperms.command.editor.url=Click the link below to open the editor +luckperms.command.editor.socket.connected=Editor window connected successfully +luckperms.command.editor.socket.reconnected=Editor window reconnected successfully +luckperms.command.editor.socket.changes-received=Changes have been received from the connected web editor session +luckperms.command.editor.socket.untrusted=An editor window has connected, but it is not yet trusted +luckperms.command.editor.socket.untrusted.prompt.click=If it was you, {0} to trust the session! +luckperms.command.editor.socket.untrusted.prompt.click.action=click here +luckperms.command.editor.socket.untrusted.prompt.runcommand=If it was you, run {0} to trust the session! +luckperms.command.editor.socket.untrusted.sessioninfo=session id = {0}, browser = {1} +luckperms.command.editor.socket.trust.success=The editor session has been marked as trusted +luckperms.command.editor.socket.trust.futureinfo=In the future, connections from the same browser will be trusted automatically +luckperms.command.editor.socket.trust.connecting=The plugin will now attempt to establish a connection with the editor... +luckperms.command.editor.socket.trust.failure=Unable to trust the given session because the socket is closed, or because a different connection was established instead luckperms.command.editor.unable-to-communicate=Unable to communicate with the editor luckperms.command.editor.apply-edits.success=Web editor data was applied to {0} {1} successfully luckperms.command.editor.apply-edits.success-summary={0} {1} and {2} {3} diff --git a/fabric/src/main/java/me/lucko/luckperms/fabric/listeners/FabricCommandListUpdater.java b/fabric/src/main/java/me/lucko/luckperms/fabric/listeners/FabricCommandListUpdater.java index 7bd512e1e..3fb1c6203 100644 --- a/fabric/src/main/java/me/lucko/luckperms/fabric/listeners/FabricCommandListUpdater.java +++ b/fabric/src/main/java/me/lucko/luckperms/fabric/listeners/FabricCommandListUpdater.java @@ -27,16 +27,16 @@ package me.lucko.luckperms.fabric.listeners; import com.github.benmanes.caffeine.cache.LoadingCache; -import me.lucko.luckperms.fabric.LPFabricPlugin; +import me.lucko.luckperms.common.api.implementation.ApiGroup; import me.lucko.luckperms.common.cache.BufferedRequest; import me.lucko.luckperms.common.event.LuckPermsEventListener; import me.lucko.luckperms.common.util.CaffeineFactory; -import me.lucko.luckperms.common.api.implementation.ApiGroup; +import me.lucko.luckperms.fabric.LPFabricPlugin; import net.luckperms.api.event.EventBus; import net.luckperms.api.event.context.ContextUpdateEvent; -import net.luckperms.api.event.user.UserDataRecalculateEvent; import net.luckperms.api.event.group.GroupDataRecalculateEvent; +import net.luckperms.api.event.user.UserDataRecalculateEvent; import net.minecraft.server.network.ServerPlayerEntity; import java.util.UUID; diff --git a/fabric/src/main/java/me/lucko/luckperms/fabric/mixin/CommandManagerMixin.java b/fabric/src/main/java/me/lucko/luckperms/fabric/mixin/CommandManagerMixin.java index 4ff389685..a08a30fcf 100644 --- a/fabric/src/main/java/me/lucko/luckperms/fabric/mixin/CommandManagerMixin.java +++ b/fabric/src/main/java/me/lucko/luckperms/fabric/mixin/CommandManagerMixin.java @@ -29,6 +29,7 @@ import me.lucko.luckperms.fabric.event.PreExecuteCommandCallback; import net.minecraft.server.command.CommandManager; import net.minecraft.server.command.ServerCommandSource; + import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject;