Skip to content
Merged
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,42 @@ Butterfly is a simple minecraft paper plugin and minestom extension/api that all
## Features
- Set scoreboard teams
- Set chat formats
- Chat color support
- Chat formatting (MiniMessage) restricted by permission, see [Chat tag permissions](#chat-tag-permissions)


## Chat tag permissions
Chat messages on Paper and Minestom are parsed as MiniMessage, but a player may only use the tags they have a permission for. Permissions are resolved through LuckPerms, one node per tag type: `butterfly.chat.tag.<type>`. Grant them per rank through LuckPerms group permissions; `butterfly.chat.tag.*` grants every type.

| Permission | Tags |
|------------|------|
| `butterfly.chat.tag.color` | named colours, hex colours, `<color:...>` |
| `butterfly.chat.tag.decoration` | `<bold>`, `<italic>`, `<underlined>`, `<strikethrough>`, `<obfuscated>` and their short forms |
| `butterfly.chat.tag.gradient` | `<gradient>` |
| `butterfly.chat.tag.rainbow` | `<rainbow>` |
| `butterfly.chat.tag.transition` | `<transition>` |
| `butterfly.chat.tag.pride` | `<pride>` |
| `butterfly.chat.tag.shadow` | `<shadow>` |
| `butterfly.chat.tag.font` | `<font>` |
| `butterfly.chat.tag.reset` | `<reset>` |
| `butterfly.chat.tag.newline` | `<newline>` / `<br>` |
| `butterfly.chat.tag.click` | `<click>` |
| `butterfly.chat.tag.hover` | `<hover>` |
| `butterfly.chat.tag.insertion` | `<insert>` |
| `butterfly.chat.tag.keybind` | `<key>` |
| `butterfly.chat.tag.translatable` | `<lang>`, `<tr>`, `<lang_or>` |
| `butterfly.chat.tag.selector` | `<selector>` |
| `butterfly.chat.tag.score` | `<score>` |
| `butterfly.chat.tag.nbt` | `<nbt>` |
| `butterfly.chat.tag.sprite` | `<sprite>` |
| `butterfly.chat.tag.head` | `<head>` |

- A tag the sender has no permission for stays in the message as literal text, exactly as typed.
- The sender's prefix and name keep their MiniMessage formatting regardless of these permissions.
- Every viewer sees the same message; it depends only on the sender's permissions.

### Upgrading to restricted chat tags
Without any `butterfly.chat.tag.*` permission, players lose all chat formatting they had before. To restore the old behaviour, run `lp group default permission set butterfly.chat.tag.* true`.

## Minestom extension
`butterfly-minestom.jar` is a Minestom extension (name `Butterfly`) and still works as a library.

Expand Down
8 changes: 8 additions & 0 deletions api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ plugins {
}

dependencies {
compileOnly(platform(libs.mycelium.bom))
compileOnly(libs.luckperms.api)
// provided by Paper and Minestom at runtime
compileOnly(libs.adventure.api)
compileOnly(libs.adventure.minimessage)
// provided by Paper and Minestom at runtime
compileOnly(libs.slf4j.api)
implementation(libs.avaje.config)

testImplementation(platform(libs.mycelium.bom))
testImplementation(libs.slf4j.api)
testImplementation(libs.adventure.api)
testImplementation(libs.adventure.minimessage)
testImplementation(libs.adventure.plain)
testImplementation(libs.luckperms.api)
testImplementation(libs.junit.api)
testImplementation(libs.junit.platform.launcher)
testImplementation(libs.junit.params)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,31 @@ default Optional<String> getPlayerPrefix(UUID playerUUID) {
return getGroupPrefix(getPrimaryGroup(playerUUID));
}

/**
* Checks a permission against the user's cached permission data, using the user's own contextual query options.
* This reads cached data only, so it is safe to call from any thread.
*
* @param user the player's LuckPerms user, may be null
* @param permission the permission node
* @return true if the permission is granted, false if it is not or the user is null
*/
default boolean hasPermission(User user, String permission) {
if (user == null) return false;
return user.getCachedData().getPermissionData(user.getQueryOptions())
.queryPermission(permission).result().asBoolean();
}

/**
* Same as {@link #hasPermission(User, String)} for a player identified by UUID.
*
* @param playerUUID the player's UUID
* @param permission the permission node
* @return true if the permission is granted, false if it is not or the user is not loaded
*/
default boolean hasPermission(UUID playerUUID, String permission) {
return hasPermission(getUser(playerUUID), permission);
}

default User getUser(UUID uuid) {
return LuckPermsProvider.get().getUserManager().getUser(uuid);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package net.onelitefeather.butterfly.api.chat;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver;

import java.util.function.Predicate;

/**
* Turns the raw text of a chat message into a component, interpreting only the MiniMessage tags the sender may
* use. Tags that are not allowed stay in the message as literal text.
*/
public final class ChatMessageParser {

/**
* Knows no tags by itself; the tags a sender may use are added per call.
*/
private static final MiniMessage MINI_MESSAGE = MiniMessage.builder().tags(TagResolver.empty()).build();

private ChatMessageParser() {
}

/**
* @param raw the message as the player typed it
* @param hasPermission checks a permission node of the sender, for example {@code butterfly.chat.tag.color}
* @return the message with the tags of all permitted {@link ChatTagType}s interpreted
*/
public static Component parse(String raw, Predicate<String> hasPermission) {
TagResolver.Builder allowed = TagResolver.builder();
for (ChatTagType tagType : ChatTagType.values()) {
if (hasPermission.test(tagType.node())) {
allowed.resolver(tagType.resolver());
}
}
return MINI_MESSAGE.deserialize(raw, allowed.build());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package net.onelitefeather.butterfly.api.chat;

import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver;
import net.kyori.adventure.text.minimessage.tag.standard.StandardTags;

import java.util.function.Supplier;

/**
* The MiniMessage tag types a chat sender can be allowed to use. Each type maps to one permission node
* ({@link #node()}) and to the tag resolvers that make its tags work.
*/
public enum ChatTagType {
COLOR("color", StandardTags::color),
DECORATION("decoration", StandardTags::decorations),
GRADIENT("gradient", StandardTags::gradient),
RAINBOW("rainbow", StandardTags::rainbow),
TRANSITION("transition", StandardTags::transition),
PRIDE("pride", StandardTags::pride),
SHADOW("shadow", StandardTags::shadowColor),
FONT("font", StandardTags::font),
RESET("reset", StandardTags::reset),
NEWLINE("newline", StandardTags::newline),
CLICK("click", StandardTags::clickEvent),
HOVER("hover", StandardTags::hoverEvent),
INSERTION("insertion", StandardTags::insertion),
KEYBIND("keybind", StandardTags::keybind),
TRANSLATABLE("translatable", () -> TagResolver.resolver(StandardTags.translatable(), StandardTags.translatableFallback())),
SELECTOR("selector", StandardTags::selector),
SCORE("score", StandardTags::score),
NBT("nbt", StandardTags::nbt),
SPRITE("sprite", StandardTags::sprite),
HEAD("head", StandardTags::sequentialHead);

/**
* Prefix shared by all tag permission nodes; {@code butterfly.chat.tag.*} grants every type.
*/
public static final String NODE_PREFIX = "butterfly.chat.tag.";

private final String type;
private final TagResolver resolver;

ChatTagType(String type, Supplier<TagResolver> resolver) {
this.type = type;
this.resolver = resolver.get();
}

/**
* @return the lower-case type name used in the permission node
*/
public String type() {
return type;
}

/**
* @return the permission node a sender needs to use this tag type
*/
public String node() {
return NODE_PREFIX + type;
}

/**
* @return the resolver that interprets the tags of this type
*/
public TagResolver resolver() {
return resolver;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package net.onelitefeather.butterfly.api;

import net.luckperms.api.cacheddata.CachedDataManager;
import net.luckperms.api.cacheddata.CachedPermissionData;
import net.luckperms.api.cacheddata.Result;
import net.luckperms.api.model.user.User;
import net.luckperms.api.query.QueryOptions;
import net.luckperms.api.util.Tristate;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.lang.reflect.Proxy;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;

class LuckPermsAPIPermissionTest {

private final LuckPermsAPI api = LuckPermsAPI.luckPermsAPI();
private final QueryOptions userOptions = stub(QueryOptions.class, (_, _) -> null);
private final List<QueryOptions> queriedWith = new CopyOnWriteArrayList<>();

@Test
@DisplayName("a permission the user's cached data grants is reported as granted")
void grantedPermissionIsTrue() {
User user = userWith(Set.of("butterfly.chat.tag.color"));

assertTrue(api.hasPermission(user, "butterfly.chat.tag.color"), "granted node must be true");
}

@Test
@DisplayName("a permission the user does not have is reported as not granted")
void missingPermissionIsFalse() {
User user = userWith(Set.of("butterfly.chat.tag.color"));

assertFalse(api.hasPermission(user, "butterfly.chat.tag.click"), "ungranted node must be false");
}

@Test
@DisplayName("the check queries the cached permission data with the user's own query options")
void usesUsersContextualQueryOptions() {
User user = userWith(Set.of("butterfly.chat.tag.color"));

api.hasPermission(user, "butterfly.chat.tag.color");

assertEquals(1, queriedWith.size(), "permission data must be requested exactly once");
assertSame(userOptions, queriedWith.getFirst(), "the user's query options must be passed through");
}

@Test
@DisplayName("a user that is not loaded has no permission")
void unloadedUserHasNoPermission() {
assertFalse(api.hasPermission((User) null, "butterfly.chat.tag.color"), "null user must be false");
}

private User userWith(Set<String> granted) {
CachedPermissionData permissions = stub(CachedPermissionData.class, (name, args) -> {
if (!name.equals("queryPermission")) return null;
Tristate state = Tristate.of(granted.contains((String) args[0]));
return stub(Result.class, (resultMethod, _) -> resultMethod.equals("result") ? state : null);
});
CachedDataManager data = stub(CachedDataManager.class, (name, args) -> {
if (!name.equals("getPermissionData")) return null;
queriedWith.add((QueryOptions) args[0]);
return permissions;
});
return stub(User.class, (name, _) -> switch (name) {
case "getCachedData" -> data;
case "getQueryOptions" -> userOptions;
default -> null;
});
}

private interface Answer {
Object answer(String method, Object[] args);
}

private static <T> T stub(Class<T> type, Answer answer) {
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, (proxy, method, args) -> {
Object[] arguments = args == null ? new Object[0] : args;
return switch (method.getName()) {
case "toString" -> type.getSimpleName() + "Stub";
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == arguments[0];
default -> answer.answer(method.getName(), arguments);
};
}));
}
}
Loading
Loading