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}