Mirror von
https://github.com/GeyserMC/Geyser.git
synchronisiert 2024-12-24 15:20:25 +01:00
Deprecate userAuths in favor of a saved token system
Dieser Commit ist enthalten in:
Ursprung
37c854b5ac
Commit
c977e36368
5
.gitignore
vendored
5
.gitignore
vendored
@ -239,8 +239,9 @@ nbdist/
|
||||
run/
|
||||
config.yml
|
||||
logs/
|
||||
public-key.pem
|
||||
key.pem
|
||||
locales/
|
||||
/cache/
|
||||
/packs/
|
||||
/dump.json
|
||||
/dump.json
|
||||
/saved-refresh-tokens.json
|
@ -275,6 +275,12 @@ public class GeyserStandaloneBootstrap implements GeyserBootstrap {
|
||||
return Paths.get(System.getProperty("user.dir"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Path getSavedUserLoginsFolder() {
|
||||
// Return the location of the config
|
||||
return new File(configFilename).getAbsoluteFile().getParentFile().toPath();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BootstrapDumpInfo getDumpInfo() {
|
||||
return new GeyserStandaloneDumpInfo(this);
|
||||
|
@ -149,7 +149,7 @@
|
||||
<dependency>
|
||||
<groupId>com.github.GeyserMC</groupId>
|
||||
<artifactId>MCAuthLib</artifactId>
|
||||
<version>6c99331</version>
|
||||
<version>d9d773e</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
|
@ -37,6 +37,8 @@ public final class Constants {
|
||||
|
||||
public static final String FLOODGATE_DOWNLOAD_LOCATION = "https://ci.opencollab.dev/job/GeyserMC/job/Floodgate/job/master/";
|
||||
|
||||
static final String SAVED_REFRESH_TOKEN_FILE = "saved-refresh-tokens.json";
|
||||
|
||||
static {
|
||||
URI wsUri = null;
|
||||
try {
|
||||
|
@ -97,6 +97,13 @@ public interface GeyserBootstrap {
|
||||
*/
|
||||
Path getConfigFolder();
|
||||
|
||||
/**
|
||||
* @return the folder where user tokens are saved. This should always point to the location of the config.
|
||||
*/
|
||||
default Path getSavedUserLoginsFolder() {
|
||||
return getConfigFolder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Information used for the bootstrap section of the debug dump
|
||||
*
|
||||
|
@ -26,6 +26,7 @@
|
||||
package org.geysermc.geyser;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.github.steveice10.packetlib.tcp.TcpSession;
|
||||
@ -37,6 +38,7 @@ import io.netty.channel.kqueue.KQueue;
|
||||
import io.netty.util.NettyRuntime;
|
||||
import io.netty.util.concurrent.DefaultThreadFactory;
|
||||
import io.netty.util.internal.SystemPropertyUtil;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.checkerframework.checker.nullness.qual.NonNull;
|
||||
@ -72,6 +74,9 @@ import org.geysermc.geyser.util.*;
|
||||
|
||||
import javax.naming.directory.Attribute;
|
||||
import javax.naming.directory.InitialDirContext;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
@ -79,6 +84,7 @@ import java.net.UnknownHostException;
|
||||
import java.security.Key;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.regex.Matcher;
|
||||
@ -127,6 +133,8 @@ public class GeyserImpl implements GeyserApi {
|
||||
private Metrics metrics;
|
||||
|
||||
private PendingMicrosoftAuthentication pendingMicrosoftAuthentication;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private Map<String, String> savedRefreshTokens;
|
||||
|
||||
private static GeyserImpl instance;
|
||||
|
||||
@ -325,7 +333,7 @@ public class GeyserImpl implements GeyserApi {
|
||||
metrics = new Metrics(this, "GeyserMC", config.getMetrics().getUniqueId(), false, java.util.logging.Logger.getLogger(""));
|
||||
metrics.addCustomChart(new Metrics.SingleLineChart("players", sessionManager::size));
|
||||
// Prevent unwanted words best we can
|
||||
metrics.addCustomChart(new Metrics.SimplePie("authMode", () -> config.getRemote().getAuthType().toString().toLowerCase()));
|
||||
metrics.addCustomChart(new Metrics.SimplePie("authMode", () -> config.getRemote().getAuthType().toString().toLowerCase(Locale.ROOT)));
|
||||
metrics.addCustomChart(new Metrics.SimplePie("platform", platformType::getPlatformName));
|
||||
metrics.addCustomChart(new Metrics.SimplePie("defaultLocale", GeyserLocale::getDefaultLocale));
|
||||
metrics.addCustomChart(new Metrics.SimplePie("version", () -> GeyserImpl.VERSION));
|
||||
@ -409,6 +417,47 @@ public class GeyserImpl implements GeyserApi {
|
||||
metrics = null;
|
||||
}
|
||||
|
||||
if (config.getRemote().getAuthType() == AuthType.ONLINE) {
|
||||
if (config.getUserAuths() != null && !config.getUserAuths().isEmpty()) {
|
||||
getLogger().warning("The 'userAuths' config section is now deprecated, and will be removed in the near future! " +
|
||||
"Please migrate to the new 'saved-user-logins' config option: " +
|
||||
"https://wiki.geysermc.org/geyser/understanding-the-config/");
|
||||
}
|
||||
|
||||
// May be written/read to on multiple threads from each GeyserSession as well as writing the config
|
||||
savedRefreshTokens = new ConcurrentHashMap<>();
|
||||
|
||||
File tokensFile = bootstrap.getSavedUserLoginsFolder().resolve(Constants.SAVED_REFRESH_TOKEN_FILE).toFile();
|
||||
if (tokensFile.exists()) {
|
||||
TypeReference<Map<String, String>> type = new TypeReference<>() { };
|
||||
|
||||
Map<String, String> refreshTokenFile = null;
|
||||
try {
|
||||
refreshTokenFile = JSON_MAPPER.readValue(tokensFile, type);
|
||||
} catch (IOException e) {
|
||||
logger.error("Cannot load saved user tokens!", e);
|
||||
}
|
||||
if (refreshTokenFile != null) {
|
||||
List<String> validUsers = config.getSavedUserLogins();
|
||||
boolean doWrite = false;
|
||||
for (Map.Entry<String, String> entry : refreshTokenFile.entrySet()) {
|
||||
String user = entry.getKey();
|
||||
if (!validUsers.contains(user)) {
|
||||
// Perform a write to this file to purge the now-unused name
|
||||
doWrite = true;
|
||||
continue;
|
||||
}
|
||||
savedRefreshTokens.put(user, entry.getValue());
|
||||
}
|
||||
if (doWrite) {
|
||||
scheduleRefreshTokensWrite();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
savedRefreshTokens = null;
|
||||
}
|
||||
|
||||
newsHandler.handleNews(null, NewsItemAction.ON_SERVER_STARTED);
|
||||
}
|
||||
|
||||
@ -516,6 +565,39 @@ public class GeyserImpl implements GeyserApi {
|
||||
return bootstrap.getWorldManager();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String refreshTokenFor(@NonNull String bedrockName) {
|
||||
return savedRefreshTokens.get(bedrockName);
|
||||
}
|
||||
|
||||
public void saveRefreshToken(@NonNull String bedrockName, @NonNull String refreshToken) {
|
||||
if (!getConfig().getSavedUserLogins().contains(bedrockName)) {
|
||||
// Do not save this login
|
||||
return;
|
||||
}
|
||||
|
||||
// We can safely overwrite old instances because MsaAuthenticationService#getLoginResponseFromRefreshToken
|
||||
// refreshes the token for us
|
||||
if (!Objects.equals(refreshToken, savedRefreshTokens.put(bedrockName, refreshToken))) {
|
||||
scheduleRefreshTokensWrite();
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleRefreshTokensWrite() {
|
||||
scheduledThread.execute(() -> {
|
||||
// Ensure all writes are handled on the same thread
|
||||
File savedTokens = getBootstrap().getSavedUserLoginsFolder().resolve(Constants.SAVED_REFRESH_TOKEN_FILE).toFile();
|
||||
TypeReference<Map<String, String>> type = new TypeReference<>() { };
|
||||
try (FileWriter writer = new FileWriter(savedTokens)) {
|
||||
JSON_MAPPER.writerFor(type)
|
||||
.withDefaultPrettyPrinter()
|
||||
.writeValue(writer, savedRefreshTokens);
|
||||
} catch (IOException e) {
|
||||
getLogger().error("Unable to write saved refresh tokens!", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static GeyserImpl getInstance() {
|
||||
return instance;
|
||||
}
|
||||
|
@ -44,6 +44,9 @@ public interface GeyserConfiguration {
|
||||
|
||||
IRemoteConfiguration getRemote();
|
||||
|
||||
List<String> getSavedUserLogins();
|
||||
|
||||
@Deprecated
|
||||
Map<String, ? extends IUserAuthenticationInfo> getUserAuths();
|
||||
|
||||
boolean isCommandSuggestions();
|
||||
|
@ -62,6 +62,9 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
|
||||
private BedrockConfiguration bedrock = new BedrockConfiguration();
|
||||
private RemoteConfiguration remote = new RemoteConfiguration();
|
||||
|
||||
@JsonProperty("saved-user-logins")
|
||||
private List<String> savedUserLogins = Collections.emptyList();
|
||||
|
||||
@JsonProperty("floodgate-key-file")
|
||||
private String floodgateKeyFile = "key.pem";
|
||||
|
||||
|
@ -190,6 +190,14 @@ public class UpstreamPacketHandler extends LoggingPacketHandler {
|
||||
}
|
||||
|
||||
private boolean couldLoginUserByName(String bedrockUsername) {
|
||||
if (geyser.getConfig().getSavedUserLogins().contains(bedrockUsername)) {
|
||||
String refreshToken = geyser.refreshTokenFor(bedrockUsername);
|
||||
if (refreshToken != null) {
|
||||
geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.auth.stored_credentials", session.getAuthData().name()));
|
||||
session.authenticateWithRefreshToken(refreshToken);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (geyser.getConfig().getUserAuths() != null) {
|
||||
GeyserConfiguration.IUserAuthenticationInfo info = geyser.getConfig().getUserAuths().get(bedrockUsername);
|
||||
|
||||
|
@ -637,7 +637,6 @@ public class GeyserSession implements GeyserConnection, CommandSender {
|
||||
loggingIn = true;
|
||||
|
||||
// Use a future to prevent timeouts as all the authentication is handled sync
|
||||
// This will be changed with the new protocol library.
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
if (password != null && !password.isEmpty()) {
|
||||
@ -694,10 +693,58 @@ public class GeyserSession implements GeyserConnection, CommandSender {
|
||||
});
|
||||
}
|
||||
|
||||
public void authenticateWithRefreshToken(String refreshToken) {
|
||||
if (loggedIn) {
|
||||
geyser.getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.auth.already_loggedin", getAuthData().name()));
|
||||
return;
|
||||
}
|
||||
|
||||
loggingIn = true;
|
||||
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
MsaAuthenticationService service = new MsaAuthenticationService(GeyserImpl.OAUTH_CLIENT_ID);
|
||||
service.setRefreshToken(refreshToken);
|
||||
try {
|
||||
service.login();
|
||||
} catch (RequestException e) {
|
||||
geyser.getLogger().error("Error while attempting to use refresh token for " + name() + "!", e);
|
||||
return Boolean.FALSE;
|
||||
}
|
||||
|
||||
GameProfile profile = service.getSelectedProfile();
|
||||
if (profile == null) {
|
||||
// Java account is offline
|
||||
disconnect(GeyserLocale.getPlayerLocaleString("geyser.network.remote.invalid_account", clientData.getLanguageCode()));
|
||||
return null;
|
||||
}
|
||||
|
||||
protocol = new MinecraftProtocol(profile, service.getAccessToken());
|
||||
geyser.saveRefreshToken(name(), service.getRefreshToken());
|
||||
return Boolean.TRUE;
|
||||
}).whenComplete((successful, ex) -> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (successful == Boolean.FALSE) {
|
||||
// The player is waiting for a spawn packet, so let's spawn them in now to show them forms
|
||||
connect();
|
||||
// Will be cached for after login
|
||||
LoginEncryptionUtils.buildAndShowTokenExpiredWindow(this);
|
||||
return;
|
||||
}
|
||||
|
||||
connectDownstream();
|
||||
});
|
||||
}
|
||||
|
||||
public void authenticateWithMicrosoftCode() {
|
||||
authenticateWithMicrosoftCode(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Present a form window to the user asking to log in with another web browser
|
||||
*/
|
||||
public void authenticateWithMicrosoftCode() {
|
||||
public void authenticateWithMicrosoftCode(boolean offlineAccess) {
|
||||
if (loggedIn) {
|
||||
geyser.getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.auth.already_loggedin", getAuthData().name()));
|
||||
return;
|
||||
@ -719,7 +766,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
|
||||
if (task.getAuthentication().isDone()) {
|
||||
onMicrosoftLoginComplete(task);
|
||||
} else {
|
||||
task.getCode().whenComplete((response, ex) -> {
|
||||
task.getCode(offlineAccess).whenComplete((response, ex) -> {
|
||||
boolean connected = !closed;
|
||||
if (ex != null) {
|
||||
if (connected) {
|
||||
@ -735,6 +782,9 @@ public class GeyserSession implements GeyserConnection, CommandSender {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If successful, also begins connecting to the Java server.
|
||||
*/
|
||||
public boolean onMicrosoftLoginComplete(PendingMicrosoftAuthentication.AuthenticationTask task) {
|
||||
if (closed) {
|
||||
return false;
|
||||
@ -745,7 +795,8 @@ public class GeyserSession implements GeyserConnection, CommandSender {
|
||||
geyser.getLogger().error("Failed to log in with Microsoft code!", ex);
|
||||
disconnect(ex.toString());
|
||||
} else {
|
||||
GameProfile selectedProfile = task.getMsaAuthenticationService().getSelectedProfile();
|
||||
MsaAuthenticationService service = task.getMsaAuthenticationService();
|
||||
GameProfile selectedProfile = service.getSelectedProfile();
|
||||
if (selectedProfile == null) {
|
||||
disconnect(GeyserLocale.getPlayerLocaleString(
|
||||
"geyser.network.remote.invalid_account",
|
||||
@ -754,9 +805,12 @@ public class GeyserSession implements GeyserConnection, CommandSender {
|
||||
} else {
|
||||
this.protocol = new MinecraftProtocol(
|
||||
selectedProfile,
|
||||
task.getMsaAuthenticationService().getAccessToken()
|
||||
service.getAccessToken()
|
||||
);
|
||||
connectDownstream();
|
||||
|
||||
// Save our refresh token for later use
|
||||
geyser.saveRefreshToken(name(), service.getRefreshToken());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -90,8 +90,6 @@ public class PendingMicrosoftAuthentication {
|
||||
@Setter
|
||||
private boolean online = true;
|
||||
|
||||
@Getter
|
||||
private final CompletableFuture<MsaAuthenticationService.MsCodeResponse> code;
|
||||
@Getter
|
||||
private final CompletableFuture<MsaAuthenticationService> authentication;
|
||||
|
||||
@ -103,11 +101,7 @@ public class PendingMicrosoftAuthentication {
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.remainingTimeMs = timeoutMs;
|
||||
|
||||
// Request the code
|
||||
this.code = CompletableFuture.supplyAsync(this::tryGetCode);
|
||||
this.authentication = new CompletableFuture<>();
|
||||
// Once the code is received, continuously try to request the access token, profile, etc
|
||||
this.code.thenRun(() -> performLoginAttempt(System.currentTimeMillis()));
|
||||
this.authentication.whenComplete((r, ex) -> {
|
||||
this.loginException = ex;
|
||||
// avoid memory leak, in case player doesn't connect again
|
||||
@ -127,9 +121,20 @@ public class PendingMicrosoftAuthentication {
|
||||
authentications.invalidate(userKey);
|
||||
}
|
||||
|
||||
private MsaAuthenticationService.MsCodeResponse tryGetCode() throws CompletionException {
|
||||
public CompletableFuture<MsaAuthenticationService.MsCodeResponse> getCode(boolean offlineAccess) {
|
||||
// Request the code
|
||||
CompletableFuture<MsaAuthenticationService.MsCodeResponse> code = CompletableFuture.supplyAsync(() -> tryGetCode(offlineAccess));
|
||||
// Once the code is received, continuously try to request the access token, profile, etc
|
||||
code.thenRun(() -> performLoginAttempt(System.currentTimeMillis()));
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param offlineAccess whether we want a refresh token for later use.
|
||||
*/
|
||||
private MsaAuthenticationService.MsCodeResponse tryGetCode(boolean offlineAccess) throws CompletionException {
|
||||
try {
|
||||
return msaAuthenticationService.getAuthCode();
|
||||
return msaAuthenticationService.getAuthCode(offlineAccess);
|
||||
} catch (RequestException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
|
@ -43,7 +43,17 @@ public class BedrockSetLocalPlayerAsInitializedTranslator extends PacketTranslat
|
||||
|
||||
if (session.getRemoteAuthType() == AuthType.ONLINE) {
|
||||
if (!session.isLoggedIn()) {
|
||||
LoginEncryptionUtils.buildAndShowLoginWindow(session);
|
||||
if (session.getGeyser().getConfig().getSavedUserLogins().contains(session.name())) {
|
||||
if (session.getGeyser().refreshTokenFor(session.name()) == null) {
|
||||
LoginEncryptionUtils.buildAndShowConsentWindow(session);
|
||||
} else {
|
||||
// If the refresh token is not null and we're here, then the refresh token expired
|
||||
// and the expiration form has been cached
|
||||
session.getFormCache().resendAllForms();
|
||||
}
|
||||
} else {
|
||||
LoginEncryptionUtils.buildAndShowLoginWindow(session);
|
||||
}
|
||||
}
|
||||
// else we were able to log the user in
|
||||
}
|
||||
|
@ -262,6 +262,48 @@ public class LoginEncryptionUtils {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a window that explains the user's credentials will be saved to the system.
|
||||
*/
|
||||
public static void buildAndShowConsentWindow(GeyserSession session) {
|
||||
String locale = session.getLocale();
|
||||
session.sendForm(
|
||||
SimpleForm.builder()
|
||||
.title("%gui.signIn")
|
||||
.content(GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.warning", locale) +
|
||||
"\n\n" +
|
||||
GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.proceed", locale))
|
||||
.button("%gui.ok")
|
||||
.button("%gui.decline")
|
||||
.responseHandler((form, responseData) -> {
|
||||
SimpleFormResponse response = form.parseResponse(responseData);
|
||||
if (response.isCorrect() && response.getClickedButtonId() == 0) {
|
||||
session.authenticateWithMicrosoftCode(true);
|
||||
} else {
|
||||
session.disconnect("%disconnect.quitting");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public static void buildAndShowTokenExpiredWindow(GeyserSession session) {
|
||||
String locale = session.getLocale();
|
||||
session.sendForm(
|
||||
SimpleForm.builder()
|
||||
.title(GeyserLocale.getPlayerLocaleString("geyser.auth.login.form.expired", locale))
|
||||
.content(GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.expired", locale) +
|
||||
"\n\n" +
|
||||
GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.proceed", locale))
|
||||
.button("%gui.ok")
|
||||
.responseHandler((form, responseData) -> {
|
||||
SimpleFormResponse response = form.parseResponse(responseData);
|
||||
if (response.isCorrect()) {
|
||||
session.authenticateWithMicrosoftCode(true);
|
||||
} else {
|
||||
session.disconnect("%disconnect.quitting");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
public static void buildAndShowLoginDetailsWindow(GeyserSession session) {
|
||||
session.sendForm(
|
||||
CustomForm.builder()
|
||||
|
@ -66,20 +66,15 @@ remote:
|
||||
# If you're using a plugin version of Floodgate on the same server, the key will automatically be picked up from Floodgate.
|
||||
floodgate-key-file: key.pem
|
||||
|
||||
# The Xbox/Minecraft Bedrock username is the key for the Java server auth-info.
|
||||
# This allows automatic configuration/login to the remote Java server.
|
||||
# If you are brave enough to put your Mojang account info into a config file.
|
||||
# Uncomment the lines below to enable this feature.
|
||||
#userAuths:
|
||||
# BedrockAccountUsername: # Your Minecraft: Bedrock Edition username
|
||||
# email: javaccountemail@example.com # Your Minecraft: Java Edition email
|
||||
# password: javaccountpassword123 # Your Minecraft: Java Edition password
|
||||
# microsoft-account: true # Whether the account is a Mojang or Microsoft account.
|
||||
#
|
||||
# bluerkelp2:
|
||||
# email: not_really_my_email_address_mr_minecrafter53267@gmail.com
|
||||
# password: "this isn't really my password"
|
||||
# microsoft-account: false
|
||||
# For online mode authentication type only.
|
||||
# Stores a list of Bedrock players that should have their Java Edition account saved after login.
|
||||
# This saves a token that can be reused to authenticate the player later. This does not save emails or passwords,
|
||||
# but you should still be cautious when adding to this list and giving others access to this Geyser instance's files.
|
||||
# Removing a name from this list will delete its cached login information on the next Geyser startup.
|
||||
# The file for this is in the same folder as this config, named "saved-refresh-tokens.json".
|
||||
saved-user-logins:
|
||||
- ThisExampleUsernameShouldBeLongEnoughToNeverBeAnXboxUsername
|
||||
- ThisOtherExampleUsernameShouldAlsoBeLongEnough
|
||||
|
||||
# Specify how many seconds to wait while user authorizes Geyser to access their Microsoft account.
|
||||
# User is allowed to disconnect from the server during this period.
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit 5db9d29ece0b3d810ae42f6bdc9eeefd76e3d99d
|
||||
Subproject commit c03eea033cb61ece135cd795ce04b34dd39a02f8
|
Laden…
In neuem Issue referenzieren
Einen Benutzer sperren