001/* 002 * PermissionsEx 003 * Copyright (C) zml and PermissionsEx contributors 004 * 005 * Licensed under the Apache License, Version 2.0 (the "License"); 006 * you may not use this file except in compliance with the License. 007 * You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package ca.stellardrift.permissionsex.minecraft; 018 019import ca.stellardrift.permissionsex.PermissionsEngine; 020import ca.stellardrift.permissionsex.PermissionsEngineBuilder; 021import ca.stellardrift.permissionsex.datastore.DataStoreFactory; 022import ca.stellardrift.permissionsex.impl.PermissionsEx; 023import ca.stellardrift.permissionsex.exception.PermissionsLoadingException; 024import ca.stellardrift.permissionsex.impl.util.PCollections; 025import ca.stellardrift.permissionsex.minecraft.command.CallbackController; 026import ca.stellardrift.permissionsex.minecraft.command.CommandException; 027import ca.stellardrift.permissionsex.minecraft.command.CommandRegistrationContext; 028import ca.stellardrift.permissionsex.minecraft.command.Commander; 029import ca.stellardrift.permissionsex.minecraft.command.Formats; 030import ca.stellardrift.permissionsex.minecraft.command.MessageFormatter; 031import ca.stellardrift.permissionsex.minecraft.command.PEXCommandPreprocessor; 032import ca.stellardrift.permissionsex.minecraft.command.definition.PermissionsExCommand; 033import ca.stellardrift.permissionsex.minecraft.command.definition.RankingCommands; 034import ca.stellardrift.permissionsex.minecraft.profile.ProfileApiResolver; 035import ca.stellardrift.permissionsex.subject.InvalidIdentifierException; 036import ca.stellardrift.permissionsex.subject.SubjectType; 037import ca.stellardrift.permissionsex.subject.SubjectTypeCollection; 038import cloud.commandframework.CommandManager; 039import cloud.commandframework.CommandTree; 040import cloud.commandframework.exceptions.CommandExecutionException; 041import cloud.commandframework.execution.AsynchronousCommandExecutionCoordinator; 042import cloud.commandframework.execution.CommandExecutionCoordinator; 043import cloud.commandframework.meta.CommandMeta; 044import cloud.commandframework.minecraft.extras.MinecraftExceptionHandler; 045import com.google.errorprone.annotations.DoNotCall; 046import net.kyori.adventure.audience.Audience; 047import net.kyori.adventure.text.Component; 048import net.kyori.adventure.text.format.NamedTextColor; 049import net.kyori.adventure.util.ComponentMessageThrowable; 050import org.checkerframework.checker.nullness.qual.Nullable; 051import org.pcollections.PMap; 052import org.slf4j.Logger; 053import org.spongepowered.configurate.util.CheckedFunction; 054 055import javax.sql.DataSource; 056import java.io.Closeable; 057import java.net.InetAddress; 058import java.net.UnknownHostException; 059import java.nio.file.Path; 060import java.sql.SQLException; 061import java.util.Locale; 062import java.util.Map; 063import java.util.Set; 064import java.util.UUID; 065import java.util.concurrent.CompletableFuture; 066import java.util.concurrent.Executor; 067import java.util.concurrent.atomic.AtomicInteger; 068import java.util.function.Consumer; 069import java.util.function.Function; 070import java.util.function.Predicate; 071import java.util.function.Supplier; 072import java.util.stream.Collectors; 073import java.util.stream.Stream; 074 075import static ca.stellardrift.permissionsex.impl.PermissionsEx.GLOBAL_CONTEXT; 076import static ca.stellardrift.permissionsex.minecraft.command.Formats.message; 077import static java.util.Objects.requireNonNull; 078import static net.kyori.adventure.text.Component.text; 079 080/** 081 * An implementation of the Minecraft-specific parts of PermissionsEx 082 * 083 * @since 2.0.0 084 */ 085public final class MinecraftPermissionsEx<T> implements Closeable { 086 087 private static final String SUBJECTS_USER = "user"; 088 private static final String SUBJECTS_GROUP = "group"; 089 090 private final PermissionsEx<T> engine; 091 private final SubjectType<UUID> users; 092 private final SubjectType<String> groups; 093 private final ProfileApiResolver resolver; 094 private final CallbackController callbacks; 095 private final @Nullable CommandManager<Commander> commands; 096 private final String commandPrefix; 097 private final @Nullable Consumer<CommandRegistrationContext> commandContributor; 098 private final MessageFormatter formatter; 099 private final Map<BaseDirectoryScope, Path> baseDirectories; 100 private final Supplier<T> platformConfigProvider; 101 102 /** 103 * Create a new builder for a Minecraft permissions engine. 104 * 105 * @return the builder 106 * @since 2.0.0 107 */ 108 public static Builder<Void> builder() { 109 return new Builder<>(Void.class); 110 } 111 112 113 /** 114 * Create a new builder for a Minecraft permissions engine. 115 * 116 * @param configType class of platform configuration 117 * @param <V> platform configuration type 118 * @return the builder 119 * @since 2.0.0 120 */ 121 public static <V> Builder<V> builder(final Class<V> configType) { 122 return new Builder<>(configType); 123 } 124 125 MinecraftPermissionsEx(final Builder<T> builder) throws PermissionsLoadingException { 126 this.baseDirectories = builder.baseDirectories; 127 final Map.Entry<PermissionsEngine, Supplier<T>> built = builder.buildEngine(); 128 this.engine = (PermissionsEx<T>) built.getKey(); 129 this.platformConfigProvider = built.getValue(); 130 this.resolver = ProfileApiResolver.resolver(this.engine.asyncExecutor()); 131 this.callbacks = new CallbackController(); 132 this.commands = builder.commandManagerMaker == null ? null : builder.commandManagerMaker.apply( 133 AsynchronousCommandExecutionCoordinator.<Commander>newBuilder() 134 .withAsynchronousParsing() 135 .withExecutor(this.engine.asyncExecutor()) 136 .build() 137 ); 138 this.commandPrefix = builder.commandPrefix; 139 this.commandContributor = builder.commandContributor; 140 final Predicate<UUID> opProvider = builder.opProvider; 141 this.users = SubjectType.builder(SUBJECTS_USER, UUID.class) 142 .serializedBy(UUID::toString) 143 .deserializedBy(id -> { 144 try { 145 return UUID.fromString(id); 146 } catch (final IllegalArgumentException ex) { 147 throw new InvalidIdentifierException(id); 148 } 149 }) 150 .friendlyNameResolvedBy(builder.cachedUuidResolver) 151 .undefinedValues(opProvider::test) 152 .associatedObjects(builder.playerProvider) 153 .build(); 154 155 // TODO: force group names to be lower-case? 156 this.groups = SubjectType.stringIdentBuilder(SUBJECTS_GROUP).build(); 157 158 convertUuids(); 159 160 // Initialize subject types 161 users(); 162 groups().cacheAll(); 163 164 this.formatter = builder.formatterProvider.apply(this); 165 this.configureCommandManager(); 166 } 167 168 /** 169 * Get the engine backing this PermissionsEx instance. 170 * 171 * @return the backing engine 172 * @since 2.0.0 173 */ 174 public PermissionsEx<T> engine() { 175 return this.engine; 176 } 177 178 /** 179 * Get user subjects. 180 * 181 * <p>User subject identifiers are UUIDs.</p> 182 * 183 * @return the collection of user subjects 184 * @since 2.0.0 185 */ 186 public SubjectTypeCollection<UUID> users() { 187 return this.engine.subjects(this.users); 188 } 189 190 /** 191 * Get group subjects. 192 * 193 * <p>Group subject identifiers are any string.</p> 194 * 195 * @return the collection of group subjects 196 * @since 2.0.0 197 */ 198 public SubjectTypeCollection<String> groups() { 199 return this.engine.subjects(this.groups); 200 } 201 202 /** 203 * Get the command callback controller for this permissions instance 204 * 205 * @return The callback controller 206 */ 207 public CallbackController callbackController() { 208 return this.callbacks; 209 } 210 211 /** 212 * Describe this PermissionsEx implementation. 213 * 214 * @param receiver the receiver for the messages 215 * @param verbose whether verbose information should be printed 216 */ 217 public void describe(final Audience receiver, final boolean verbose) { 218 receiver.sendMessage(text() 219 .content("PermissionsEx v") 220 .append(text(this.engine.version()))); // highlight? make message formatter a manager thing? 221 222 receiver.sendMessage(Messages.DESCRIBE_RESPONSE_ACTIVE_DATA_STORE.tr(this.engine.config().getDefaultDataStore().identifier())); 223 receiver.sendMessage(Messages.DESCRIBE_RESPONSE_AVAILABLE_DATA_STORES.tr(DataStoreFactory.all().keySet().toString())); 224 receiver.sendMessage(Component.empty()); 225 if (verbose) { 226 receiver.sendMessage(this.formatter.header(Messages.DESCRIBE_BASEDIRS_HEADER.bTr()).build()); 227 receiver.sendMessage(Messages.DESCRIBE_BASEDIRS_CONFIG.tr(this.baseDirectory(BaseDirectoryScope.CONFIG))); 228 receiver.sendMessage(Messages.DESCRIBE_BASEDIRS_JAR.tr(this.baseDirectory(BaseDirectoryScope.JAR))); 229 receiver.sendMessage(Messages.DESCRIBE_BASEDIRS_SERVER.tr(this.baseDirectory(BaseDirectoryScope.SERVER))); 230 receiver.sendMessage(Messages.DESCRIBE_BASEDIRS_WORLDS.tr(this.baseDirectory(BaseDirectoryScope.WORLDS))); 231 } 232 } 233 234 /** 235 * Get a game-specific base directory for a certain socpe. 236 * 237 * @param scope the scope 238 * @return a base directory 239 * @since 2.0.0 240 */ 241 public Path baseDirectory(final BaseDirectoryScope scope) { 242 final @Nullable Path baseDir = this.baseDirectories.get(scope); 243 return baseDir == null ? this.engine.baseDirectory() : baseDir; 244 } 245 246 /** 247 * Get a message formatter that can be used for styling user output. 248 * 249 * @return the formatter 250 */ 251 public MessageFormatter messageFormatter() { 252 return this.formatter; 253 } 254 255 /** 256 * Get the platform-specific configuration section. 257 * 258 * @return the platform configuration instance 259 */ 260 public T platformConfig() { 261 return this.platformConfigProvider.get(); 262 } 263 264 private void configureCommandManager() { 265 if (this.commands == null) { 266 return; 267 } 268 269 // Configure error handling 270 new MinecraftExceptionHandler<Commander>() 271 .withDefaultHandlers() 272 .withHandler(MinecraftExceptionHandler.ExceptionType.ARGUMENT_PARSING, e -> 273 Component.text("Invalid command argument: ", NamedTextColor.RED) 274 .append(message(e.getCause()).colorIfAbsent(NamedTextColor.GRAY))) 275 .withDecorator(component -> component.colorIfAbsent(NamedTextColor.RED)) 276 .apply(this.commands, cmd -> cmd); 277 278 this.commands.registerExceptionHandler(CommandException.class, (sender, err) -> { 279 final @Nullable Component message = err.componentMessage(); 280 sender.error(message == null ? text("An unknown error occurred in this command") : message, err.getCause()); 281 }); 282 this.commands.registerExceptionHandler(CommandExecutionException.class, (sender, err) -> { 283 final @Nullable Throwable cause = err.getCause(); 284 if (cause instanceof CommandException) { 285 sender.error(((CommandException) cause).componentMessage(), cause.getCause()); 286 } else if (cause != null) { 287 sender.error(Messages.COMMAND_ERROR_UNKNOWN.tr(), cause); 288 this.engine.logger().error(Messages.COMMAND_ERROR_UNKNOWN_CONSOLE.tr( 289 /* sender */ sender.name(), 290 /* command */ err.getCommandContext().getRawInputJoined(), 291 /* message */ Formats.message(cause) 292 )); 293 } 294 }); 295 296 this.commands.registerCommandPreProcessor(new PEXCommandPreprocessor(this)); 297 298 // Register custom argument parsers 299 if (this.hasBrigadier()) { 300 BrigadierRegistration.registerArgumentTypes(this.commands); 301 } 302 303 // And register commands 304 final CommandRegistrationContext regCtx = new CommandRegistrationContext(this.commandPrefix, this, this.commands); 305 regCtx.push(regCtx.absoluteBuilder("permissionsex", "pex") 306 .meta(CommandMeta.DESCRIPTION, "The command for controlling PermissionsEx"), child -> { 307 PermissionsExCommand.register(child); 308 this.callbackController().registerCommand(child); 309 }); 310 311 regCtx.register(RankingCommands::promote, "promote", "prom", "rankup"); 312 regCtx.register(RankingCommands::demote, "demote", "dem", "rankdown"); 313 314 if (this.commandContributor != null) { 315 this.commandContributor.accept(regCtx); 316 } 317 } 318 319 private boolean hasBrigadier() { 320 try { 321 Class.forName("cloud.commandframework.brigadier.BrigadierManagerHolder"); 322 Class.forName("com.mojang.brigadier.CommandDispatcher"); 323 return true; 324 } catch (final ClassNotFoundException ex) { 325 return false; 326 } 327 } 328 329 private void convertUuids() { 330 try { 331 InetAddress.getByName("api.mojang.com"); 332 } catch (final UnknownHostException ex) { 333 engine.logger().warn(Messages.UUIDCONVERSION_ERROR_DNS.tr()); 334 } 335 336 // Low-level operation 337 this.engine.doBulkOperation(store -> { 338 final Set<String> toConvert = store.getAllIdentifiers(SUBJECTS_USER) 339 .filter(ident -> { 340 if (ident.length() != 36) { 341 return true; 342 } 343 try { 344 UUID.fromString(ident); 345 return false; 346 } catch (IllegalArgumentException ex) { 347 return true; 348 } 349 }).collect(Collectors.toSet()); 350 if (!toConvert.isEmpty()) { 351 engine.logger().info(Messages.UUIDCONVERSION_BEGIN.tr()); 352 } else { 353 return CompletableFuture.completedFuture(0L); 354 } 355 356 final AtomicInteger successCount = new AtomicInteger(); 357 final Stream<CompletableFuture<Void>> results = this.resolver.resolveByName(toConvert) 358 .map(profile -> { 359 final String newIdentifier = profile.uuid().toString(); 360 final String lookupName = profile.name(); 361 362 // newRegistered <- registered(uuid) 363 final CompletableFuture<Boolean> newRegistered = store.isRegistered(SUBJECTS_USER, newIdentifier); 364 // oldRegistered <- registered(username || lowercaseUsername) 365 final CompletableFuture<Boolean> oldRegistered = store.isRegistered(SUBJECTS_USER, lookupName).thenCombine( 366 store.isRegistered(SUBJECTS_USER, lookupName.toLowerCase(Locale.ROOT)), (a, b) -> a || b 367 ); 368 369 // shouldExecute <- !newRegistered && oldRegistered 370 final CompletableFuture<Boolean> shouldExecute = newRegistered.thenCombine(oldRegistered, (n, o) -> { 371 if (n) { 372 this.engine.logger().warn(Messages.UUIDCONVERSION_ERROR_DUPLICATE.tr(newIdentifier)); 373 return false; 374 } else { 375 return o; 376 } 377 }); 378 379 return shouldExecute.thenCompose(execute -> { // execute <- shouldExecute 380 if (!execute) { 381 return CompletableFuture.completedFuture(null); 382 } 383 384 // Actually move the data 385 return store.getData(SUBJECTS_USER, profile.name(), null) 386 .thenCompose(oldData -> store.setData( 387 SUBJECTS_USER, 388 newIdentifier, 389 oldData.withSegment(GLOBAL_CONTEXT, s -> s.withOption("name", profile.name())) 390 ) 391 .thenAccept(result -> store.setData(SUBJECTS_USER, profile.name(), null) 392 .whenComplete((value, err) -> { 393 if (err != null) { 394 err.printStackTrace(); 395 } else { 396 successCount.getAndIncrement(); 397 } 398 }))); 399 400 }); 401 }); 402 403 @SuppressWarnings("unchecked") 404 final CompletableFuture<Void>[] futureArray = results.toArray(CompletableFuture[]::new); 405 return CompletableFuture.allOf(futureArray) 406 .<Number>thenApply($ -> successCount.get()); 407 }).thenAccept(result -> { 408 if (result != null && result.intValue() > 0) { 409 engine.logger().info(Messages.UUIDCONVERSION_END.tr(result)); 410 } 411 }).exceptionally(t -> { 412 engine.logger().error(Messages.UUIDCONVERSION_ERROR_GENERAL.tr(), t); 413 return null; 414 }); 415 } 416 417 @Override 418 public void close() { 419 this.engine.close(); 420 } 421 422 /** 423 * A builder for a Minecraft PermissionsEx engine. 424 * 425 * @since 2.0.0 426 */ 427 public static final class Builder<C> implements PermissionsEngineBuilder<C> { 428 429 private final PermissionsEngineBuilder<C> delegate; 430 private Function<String, @Nullable UUID> cachedUuidResolver = $ -> null; 431 private Predicate<UUID> opProvider = $ -> false; 432 private Function<UUID, @Nullable ?> playerProvider = $ -> null; 433 private @Nullable Function<Function<CommandTree<Commander>, CommandExecutionCoordinator<Commander>>, CommandManager<Commander>> commandManagerMaker; 434 private String commandPrefix = ""; 435 private @Nullable Consumer<CommandRegistrationContext> commandContributor; 436 private Function<MinecraftPermissionsEx<C>, MessageFormatter> formatterProvider = MessageFormatter::new; 437 private PMap<BaseDirectoryScope, Path> baseDirectories = PCollections.map(); 438 439 Builder(final Class<C> configType) { 440 this.delegate = PermissionsEngine.builder(configType); 441 } 442 443 /** 444 * Provide a profile resolver to get player UUIDs from cache given a name. 445 * 446 * @param resolver the uuid resolver 447 * @return this builder 448 * @since 2.0.0 449 */ 450 public Builder<C> cachedUuidResolver(final Function<String, @Nullable UUID> resolver) { 451 this.cachedUuidResolver = requireNonNull(resolver, "uuid"); 452 return this; 453 } 454 455 /** 456 * Set a predicate that will check whether a certain player UUID has op status. 457 * 458 * @param provider the op status provider 459 * @return this builder 460 * @since 2.0.0 461 */ 462 public Builder<C> opProvider(final Predicate<UUID> provider) { 463 this.opProvider = requireNonNull(provider, "provider"); 464 return this; 465 } 466 467 /** 468 * Set a function that will look up players by UUID, to provide an associated object 469 * for subjects. 470 * 471 * @param playerProvider the player provider 472 * @return this builder 473 * @since 2.0.0 474 */ 475 public Builder<C> playerProvider(final Function<UUID, @Nullable ?> playerProvider) { 476 this.playerProvider = requireNonNull(playerProvider, "playerProvider"); 477 return this; 478 } 479 480 /** 481 * If commands should be registered, set the command manager to register with. 482 * 483 * @param manager the manager 484 * @return this builder 485 * @since 2.0.0 486 */ 487 public Builder<C> commands( 488 final Function<Function<CommandTree<Commander>, CommandExecutionCoordinator<Commander>>, CommandManager<Commander>> manager 489 ) { 490 this.commands(manager, ""); 491 return this; 492 } 493 494 /** 495 * If commands should be registered, set the command manager to register with. 496 * 497 * @param manager the manager 498 * @return this builder 499 * @since 2.0.0 500 */ 501 public Builder<C> commands( 502 final Function<Function<CommandTree<Commander>, CommandExecutionCoordinator<Commander>>, CommandManager<Commander>> manager, 503 final String commandPrefix 504 ) { 505 this.commandManagerMaker = requireNonNull(manager, "manager"); 506 this.commandPrefix = requireNonNull(commandPrefix, "commandPrefix"); 507 return this; 508 } 509 510 /** 511 * Register a callback function that will contribute commands to be registered when PEX performs 512 * command registration. 513 * 514 * @param contributor the contributor 515 * @return this builder 516 * @since 2.0.0 517 */ 518 public Builder<C> commandContributor(final Consumer<CommandRegistrationContext> contributor) { 519 this.commandContributor = requireNonNull(contributor, "contributor"); 520 return this; 521 } 522 523 /** 524 * Set a message formatter to be used for instance-specific formatting. 525 * 526 * <p>This can be used to override the colour scheme.</p> 527 * 528 * @param formatterProvider a function that creates a message formatter for this provider 529 * @return this builder 530 * @since 2.0.0 531 */ 532 public Builder<C> messageFormatter(final Function<MinecraftPermissionsEx<C>, MessageFormatter> formatterProvider) { 533 this.formatterProvider = formatterProvider; 534 return this; 535 } 536 537 @Override 538 public Builder<C> configuration(final Path configFile) { 539 this.delegate.configuration(configFile); 540 return this; 541 } 542 543 @Override 544 public Builder<C> baseDirectory(final Path baseDir) { 545 this.delegate.baseDirectory(baseDir); 546 return this.baseDirectory(BaseDirectoryScope.CONFIG, baseDir); 547 } 548 549 /** 550 * Set the base directory in a certain game-specific scope. 551 * 552 * @param scope the scope to set a base directory in 553 * @param baseDirectory the base directory 554 * @return this builder 555 * @since 2.0.0 556 */ 557 public Builder<C> baseDirectory(final BaseDirectoryScope scope, final Path baseDirectory) { 558 this.baseDirectories = this.baseDirectories.plus(scope, baseDirectory); 559 return this; 560 } 561 562 @Override 563 public Builder<C> logger(final Logger logger) { 564 this.delegate.logger(logger); 565 return this; 566 } 567 568 @Override 569 public Builder<C> asyncExecutor(final Executor executor) { 570 this.delegate.asyncExecutor(executor); 571 return this; 572 } 573 574 @Override 575 public Builder<C> databaseProvider(final CheckedFunction<String, @Nullable DataSource, SQLException> databaseProvider) { 576 this.delegate.databaseProvider(databaseProvider); 577 return this; 578 } 579 580 /** 581 * This method should not be called directly. 582 * 583 * @return a new permissions engine 584 */ 585 @Override 586 @Deprecated 587 @DoNotCall 588 public PermissionsEngine build() throws PermissionsLoadingException { 589 throw new IllegalArgumentException("Call create() instead"); 590 } 591 592 /** 593 * This method should not be called directly. 594 * 595 * @return a new permissions engine 596 */ 597 @Override 598 @Deprecated 599 @DoNotCall 600 public Map.Entry<PermissionsEngine, Supplier<C>> buildWithConfig() { 601 throw new IllegalArgumentException("Call create() instead"); 602 } 603 604 Map.Entry<PermissionsEngine, Supplier<C>> buildEngine() throws PermissionsLoadingException { 605 return this.delegate.buildWithConfig(); 606 } 607 608 /** 609 * Build an engine. 610 * 611 * <p>The implementation interface must have been set.</p> 612 * 613 * @return a new instance 614 * @throws PermissionsLoadingException if unable to load initial data 615 * @since 2.0.0 616 */ 617 public MinecraftPermissionsEx<C> create() throws PermissionsLoadingException { 618 return new MinecraftPermissionsEx<>(this); 619 } 620 621 } 622 623}