mirror of
https://github.com/plan-player-analytics/Plan.git
synced 2025-01-20 07:02:21 +01:00
Refactored static resource resolution
This commit is contained in:
parent
77dbc74cc5
commit
2b1b459a37
@ -23,7 +23,7 @@ public final class MimeType {
|
|||||||
public static final String JS = "application/javascript";
|
public static final String JS = "application/javascript";
|
||||||
public static final String IMAGE = "image/gif";
|
public static final String IMAGE = "image/gif";
|
||||||
public static final String FAVICON = "image/x-icon";
|
public static final String FAVICON = "image/x-icon";
|
||||||
public static final String FONT_TFF = "application/x-font-ttf";
|
public static final String FONT_TTF = "application/x-font-ttf";
|
||||||
public static final String FONT_WOFF = "application/font-woff";
|
public static final String FONT_WOFF = "application/font-woff";
|
||||||
public static final String FONT_WOFF2 = "application/font-woff2";
|
public static final String FONT_WOFF2 = "application/font-woff2";
|
||||||
public static final String FONT_EOT = "application/vnd.ms-fontobject";
|
public static final String FONT_EOT = "application/vnd.ms-fontobject";
|
||||||
|
@ -27,13 +27,11 @@ import com.djrapitops.plan.delivery.webserver.pages.*;
|
|||||||
import com.djrapitops.plan.delivery.webserver.pages.json.RootJSONResolver;
|
import com.djrapitops.plan.delivery.webserver.pages.json.RootJSONResolver;
|
||||||
import com.djrapitops.plan.delivery.webserver.response.OptionsResponse;
|
import com.djrapitops.plan.delivery.webserver.response.OptionsResponse;
|
||||||
import com.djrapitops.plan.delivery.webserver.response.ResponseFactory;
|
import com.djrapitops.plan.delivery.webserver.response.ResponseFactory;
|
||||||
import com.djrapitops.plan.delivery.webserver.response.Response_old;
|
|
||||||
import com.djrapitops.plan.exceptions.WebUserAuthException;
|
import com.djrapitops.plan.exceptions.WebUserAuthException;
|
||||||
import com.djrapitops.plan.exceptions.connection.BadRequestException;
|
import com.djrapitops.plan.exceptions.connection.BadRequestException;
|
||||||
import com.djrapitops.plan.exceptions.connection.ForbiddenException;
|
import com.djrapitops.plan.exceptions.connection.ForbiddenException;
|
||||||
import com.djrapitops.plan.exceptions.connection.NotFoundException;
|
import com.djrapitops.plan.exceptions.connection.NotFoundException;
|
||||||
import com.djrapitops.plan.exceptions.connection.WebException;
|
import com.djrapitops.plan.exceptions.connection.WebException;
|
||||||
import com.djrapitops.plan.identification.ServerInfo;
|
|
||||||
import com.djrapitops.plugin.logging.L;
|
import com.djrapitops.plugin.logging.L;
|
||||||
import com.djrapitops.plugin.logging.error.ErrorHandler;
|
import com.djrapitops.plugin.logging.error.ErrorHandler;
|
||||||
import dagger.Lazy;
|
import dagger.Lazy;
|
||||||
@ -60,9 +58,9 @@ public class ResponseResolver extends CompositePageResolver {
|
|||||||
private final ServerPageResolver serverPageResolver;
|
private final ServerPageResolver serverPageResolver;
|
||||||
private final RootPageResolver rootPageResolver;
|
private final RootPageResolver rootPageResolver;
|
||||||
private final RootJSONResolver rootJSONResolver;
|
private final RootJSONResolver rootJSONResolver;
|
||||||
|
private final StaticResourceResolver staticResourceResolver;
|
||||||
private final ErrorHandler errorHandler;
|
private final ErrorHandler errorHandler;
|
||||||
|
|
||||||
private final ServerInfo serverInfo;
|
|
||||||
private final ResolverService resolverService;
|
private final ResolverService resolverService;
|
||||||
private final Lazy<WebServer> webServer;
|
private final Lazy<WebServer> webServer;
|
||||||
|
|
||||||
@ -71,7 +69,6 @@ public class ResponseResolver extends CompositePageResolver {
|
|||||||
ResolverSvc resolverService,
|
ResolverSvc resolverService,
|
||||||
ResponseFactory responseFactory,
|
ResponseFactory responseFactory,
|
||||||
Lazy<WebServer> webServer,
|
Lazy<WebServer> webServer,
|
||||||
ServerInfo serverInfo,
|
|
||||||
|
|
||||||
DebugPageResolver debugPageResolver,
|
DebugPageResolver debugPageResolver,
|
||||||
PlayersPageResolver playersPageResolver,
|
PlayersPageResolver playersPageResolver,
|
||||||
@ -79,19 +76,20 @@ public class ResponseResolver extends CompositePageResolver {
|
|||||||
ServerPageResolver serverPageResolver,
|
ServerPageResolver serverPageResolver,
|
||||||
RootPageResolver rootPageResolver,
|
RootPageResolver rootPageResolver,
|
||||||
RootJSONResolver rootJSONResolver,
|
RootJSONResolver rootJSONResolver,
|
||||||
|
StaticResourceResolver staticResourceResolver,
|
||||||
|
|
||||||
ErrorHandler errorHandler
|
ErrorHandler errorHandler
|
||||||
) {
|
) {
|
||||||
super(responseFactory);
|
super(responseFactory);
|
||||||
this.resolverService = resolverService;
|
this.resolverService = resolverService;
|
||||||
this.webServer = webServer;
|
this.webServer = webServer;
|
||||||
this.serverInfo = serverInfo;
|
|
||||||
this.debugPageResolver = debugPageResolver;
|
this.debugPageResolver = debugPageResolver;
|
||||||
this.playersPageResolver = playersPageResolver;
|
this.playersPageResolver = playersPageResolver;
|
||||||
this.playerPageResolver = playerPageResolver;
|
this.playerPageResolver = playerPageResolver;
|
||||||
this.serverPageResolver = serverPageResolver;
|
this.serverPageResolver = serverPageResolver;
|
||||||
this.rootPageResolver = rootPageResolver;
|
this.rootPageResolver = rootPageResolver;
|
||||||
this.rootJSONResolver = rootJSONResolver;
|
this.rootJSONResolver = rootJSONResolver;
|
||||||
|
this.staticResourceResolver = staticResourceResolver;
|
||||||
this.errorHandler = errorHandler;
|
this.errorHandler = errorHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,6 +102,7 @@ public class ResponseResolver extends CompositePageResolver {
|
|||||||
resolverService.registerResolver(pluginName, "/network", serverPageResolver);
|
resolverService.registerResolver(pluginName, "/network", serverPageResolver);
|
||||||
resolverService.registerResolver(pluginName, "/server", serverPageResolver);
|
resolverService.registerResolver(pluginName, "/server", serverPageResolver);
|
||||||
resolverService.registerResolverForMatches(pluginName, Pattern.compile("^/$"), rootPageResolver);
|
resolverService.registerResolverForMatches(pluginName, Pattern.compile("^/$"), rootPageResolver);
|
||||||
|
resolverService.registerResolverForMatches(pluginName, Pattern.compile("^/(vendor|css|js|img)/.*"), staticResourceResolver);
|
||||||
|
|
||||||
registerPage("v1", rootJSONResolver);
|
registerPage("v1", rootJSONResolver);
|
||||||
}
|
}
|
||||||
@ -137,8 +136,7 @@ public class ResponseResolver extends CompositePageResolver {
|
|||||||
Optional<Authentication> authentication = internalRequest.getAuth();
|
Optional<Authentication> authentication = internalRequest.getAuth();
|
||||||
|
|
||||||
Optional<Resolver> foundResolver = resolverService.getResolver(internalRequest.getPath().asString());
|
Optional<Resolver> foundResolver = resolverService.getResolver(internalRequest.getPath().asString());
|
||||||
// TODO Replace with 404 after refactoring
|
if (!foundResolver.isPresent()) return responseFactory.pageNotFound404();
|
||||||
if (!foundResolver.isPresent()) return tryToGetResponse_old(internalRequest).toNewResponse();
|
|
||||||
|
|
||||||
Resolver resolver = foundResolver.get();
|
Resolver resolver = foundResolver.get();
|
||||||
|
|
||||||
@ -163,40 +161,4 @@ public class ResponseResolver extends CompositePageResolver {
|
|||||||
return resolver.resolve(request).orElseGet(responseFactory::pageNotFound404);
|
return resolver.resolve(request).orElseGet(responseFactory::pageNotFound404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Response_old tryToGetResponse_old(RequestInternal request) throws WebException {
|
|
||||||
RequestTarget target = request.getRequestTarget();
|
|
||||||
Optional<Authentication> authentication = request.getAuth();
|
|
||||||
String resource = target.getResourceString();
|
|
||||||
// TODO Turn into resolvers
|
|
||||||
if (target.endsWith(".css")) {
|
|
||||||
return responseFactory.cssResponse_old(resource);
|
|
||||||
}
|
|
||||||
if (target.endsWith(".js")) {
|
|
||||||
return responseFactory.javaScriptResponse_old(resource);
|
|
||||||
}
|
|
||||||
if (target.endsWith(".png")) {
|
|
||||||
return responseFactory.imageResponse_old(resource);
|
|
||||||
}
|
|
||||||
if (target.endsWithAny(".woff", ".woff2", ".eot", ".ttf")) {
|
|
||||||
return responseFactory.fontResponse_old(resource);
|
|
||||||
}
|
|
||||||
boolean isAuthRequired = webServer.get().isAuthRequired();
|
|
||||||
if (isAuthRequired && !authentication.isPresent()) {
|
|
||||||
if (webServer.get().isUsingHTTPS()) {
|
|
||||||
return responseFactory.basicAuth_old();
|
|
||||||
} else {
|
|
||||||
return responseFactory.forbidden403_old();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PageResolver pageResolver = getPageResolver(target);
|
|
||||||
if (pageResolver == null) {
|
|
||||||
return responseFactory.pageNotFound404_old();
|
|
||||||
} else {
|
|
||||||
if (!isAuthRequired || pageResolver.isAuthorized(authentication.get(), target)) {
|
|
||||||
return pageResolver.resolve(request, target);
|
|
||||||
}
|
|
||||||
return responseFactory.forbidden403_old();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of Player Analytics (Plan).
|
||||||
|
*
|
||||||
|
* Plan is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Lesser General Public License v3 as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* Plan is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Lesser General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Lesser General Public License
|
||||||
|
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.djrapitops.plan.delivery.webserver.pages;
|
||||||
|
|
||||||
|
import com.djrapitops.plan.delivery.web.resolver.NoAuthResolver;
|
||||||
|
import com.djrapitops.plan.delivery.web.resolver.Response;
|
||||||
|
import com.djrapitops.plan.delivery.web.resolver.request.Request;
|
||||||
|
import com.djrapitops.plan.delivery.webserver.response.ResponseFactory;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import javax.inject.Inject;
|
||||||
|
import javax.inject.Singleton;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves all static resources for the pages.
|
||||||
|
*
|
||||||
|
* @author Rsl1122
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
public class StaticResourceResolver implements NoAuthResolver {
|
||||||
|
|
||||||
|
private final ResponseFactory responseFactory;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public StaticResourceResolver(ResponseFactory responseFactory) {
|
||||||
|
this.responseFactory = responseFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Response> resolve(Request request) {
|
||||||
|
return Optional.ofNullable(getResponse(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Response getResponse(Request request) {
|
||||||
|
String resource = request.getPath().asString();
|
||||||
|
String filePath = "web" + resource;
|
||||||
|
if (resource.endsWith(".css")) {
|
||||||
|
return responseFactory.cssResponse(filePath);
|
||||||
|
}
|
||||||
|
if (resource.endsWith(".js")) {
|
||||||
|
return responseFactory.javaScriptResponse(filePath);
|
||||||
|
}
|
||||||
|
if (resource.endsWith(".png")) {
|
||||||
|
return responseFactory.imageResponse(filePath);
|
||||||
|
}
|
||||||
|
if (StringUtils.endsWithAny(resource, ".woff", ".woff2", ".eot", ".ttf")) {
|
||||||
|
return responseFactory.fontResponse(filePath);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@ -25,7 +25,6 @@ import com.djrapitops.plan.delivery.web.resolver.MimeType;
|
|||||||
import com.djrapitops.plan.delivery.web.resolver.Response;
|
import com.djrapitops.plan.delivery.web.resolver.Response;
|
||||||
import com.djrapitops.plan.delivery.webserver.auth.FailReason;
|
import com.djrapitops.plan.delivery.webserver.auth.FailReason;
|
||||||
import com.djrapitops.plan.delivery.webserver.response.errors.ErrorResponse;
|
import com.djrapitops.plan.delivery.webserver.response.errors.ErrorResponse;
|
||||||
import com.djrapitops.plan.delivery.webserver.response.errors.ForbiddenResponse;
|
|
||||||
import com.djrapitops.plan.delivery.webserver.response.errors.InternalErrorResponse;
|
import com.djrapitops.plan.delivery.webserver.response.errors.InternalErrorResponse;
|
||||||
import com.djrapitops.plan.delivery.webserver.response.errors.NotFoundResponse;
|
import com.djrapitops.plan.delivery.webserver.response.errors.NotFoundResponse;
|
||||||
import com.djrapitops.plan.delivery.webserver.response.pages.RawDataResponse;
|
import com.djrapitops.plan.delivery.webserver.response.pages.RawDataResponse;
|
||||||
@ -33,6 +32,7 @@ import com.djrapitops.plan.exceptions.WebUserAuthException;
|
|||||||
import com.djrapitops.plan.exceptions.connection.NotFoundException;
|
import com.djrapitops.plan.exceptions.connection.NotFoundException;
|
||||||
import com.djrapitops.plan.settings.locale.Locale;
|
import com.djrapitops.plan.settings.locale.Locale;
|
||||||
import com.djrapitops.plan.settings.locale.lang.ErrorPageLang;
|
import com.djrapitops.plan.settings.locale.lang.ErrorPageLang;
|
||||||
|
import com.djrapitops.plan.settings.theme.Theme;
|
||||||
import com.djrapitops.plan.storage.database.DBSystem;
|
import com.djrapitops.plan.storage.database.DBSystem;
|
||||||
import com.djrapitops.plan.storage.database.Database;
|
import com.djrapitops.plan.storage.database.Database;
|
||||||
import com.djrapitops.plan.storage.database.queries.containers.ContainerFetchQueries;
|
import com.djrapitops.plan.storage.database.queries.containers.ContainerFetchQueries;
|
||||||
@ -48,7 +48,7 @@ import java.util.Optional;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory for creating different {@link Response_old} objects.
|
* Factory for creating different {@link Response} objects.
|
||||||
*
|
*
|
||||||
* @author Rsl1122
|
* @author Rsl1122
|
||||||
*/
|
*/
|
||||||
@ -60,6 +60,7 @@ public class ResponseFactory {
|
|||||||
private final PageFactory pageFactory;
|
private final PageFactory pageFactory;
|
||||||
private final Locale locale;
|
private final Locale locale;
|
||||||
private final DBSystem dbSystem;
|
private final DBSystem dbSystem;
|
||||||
|
private final Theme theme;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public ResponseFactory(
|
public ResponseFactory(
|
||||||
@ -67,13 +68,15 @@ public class ResponseFactory {
|
|||||||
PlanFiles files,
|
PlanFiles files,
|
||||||
PageFactory pageFactory,
|
PageFactory pageFactory,
|
||||||
Locale locale,
|
Locale locale,
|
||||||
DBSystem dbSystem
|
DBSystem dbSystem,
|
||||||
|
Theme theme
|
||||||
) {
|
) {
|
||||||
this.versionCheckSystem = versionCheckSystem;
|
this.versionCheckSystem = versionCheckSystem;
|
||||||
this.files = files;
|
this.files = files;
|
||||||
this.pageFactory = pageFactory;
|
this.pageFactory = pageFactory;
|
||||||
this.locale = locale;
|
this.locale = locale;
|
||||||
this.dbSystem = dbSystem;
|
this.dbSystem = dbSystem;
|
||||||
|
this.theme = theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Response debugPageResponse() {
|
public Response debugPageResponse() {
|
||||||
@ -179,42 +182,65 @@ public class ResponseFactory {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated
|
public Response javaScriptResponse(String fileName) {
|
||||||
public Response_old javaScriptResponse_old(String fileName) {
|
|
||||||
try {
|
try {
|
||||||
return new JavaScriptResponse(fileName, files, locale);
|
String content = locale.replaceLanguageInJavascript(files.getCustomizableResourceOrDefault(fileName).asString());
|
||||||
|
return Response.builder()
|
||||||
|
.setMimeType(MimeType.JS)
|
||||||
|
.setContent(content)
|
||||||
|
.setStatus(200)
|
||||||
|
.build();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return notFound404_old("JS File not found from jar: " + fileName + ", " + e.toString());
|
return notFound404("JS File not found from jar: " + fileName + ", " + e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated
|
public Response cssResponse(String fileName) {
|
||||||
public Response_old cssResponse_old(String fileName) {
|
|
||||||
try {
|
try {
|
||||||
return new CSSResponse(fileName, files);
|
String content = theme.replaceThemeColors(files.getCustomizableResourceOrDefault(fileName).asString());
|
||||||
|
return Response.builder()
|
||||||
|
.setMimeType(MimeType.CSS)
|
||||||
|
.setContent(content)
|
||||||
|
.setStatus(200)
|
||||||
|
.build();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return notFound404_old("CSS File not found from jar: " + fileName + ", " + e.toString());
|
return notFound404("CSS File not found from jar: " + fileName + ", " + e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated
|
public Response imageResponse(String fileName) {
|
||||||
public Response_old imageResponse_old(String fileName) {
|
try {
|
||||||
return new ByteResponse(ResponseType.IMAGE, FileResponse.format(fileName), files);
|
return Response.builder()
|
||||||
|
.setMimeType(MimeType.IMAGE)
|
||||||
|
.setContent(files.getCustomizableResourceOrDefault(fileName).asBytes())
|
||||||
|
.setStatus(200)
|
||||||
|
.build();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return notFound404("Image File not found from jar: " + fileName + ", " + e.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated
|
public Response fontResponse(String fileName) {
|
||||||
public Response_old fontResponse_old(String fileName) {
|
String type;
|
||||||
ResponseType type = ResponseType.FONT_BYTESTREAM;
|
|
||||||
if (fileName.endsWith(".woff")) {
|
if (fileName.endsWith(".woff")) {
|
||||||
type = ResponseType.FONT_WOFF;
|
type = MimeType.FONT_WOFF;
|
||||||
} else if (fileName.endsWith(".woff2")) {
|
} else if (fileName.endsWith(".woff2")) {
|
||||||
type = ResponseType.FONT_WOFF2;
|
type = MimeType.FONT_WOFF2;
|
||||||
} else if (fileName.endsWith(".eot")) {
|
} else if (fileName.endsWith(".eot")) {
|
||||||
type = ResponseType.FONT_EOT;
|
type = MimeType.FONT_EOT;
|
||||||
} else if (fileName.endsWith(".ttf")) {
|
} else if (fileName.endsWith(".ttf")) {
|
||||||
type = ResponseType.FONT_TTF;
|
type = MimeType.FONT_TTF;
|
||||||
|
} else {
|
||||||
|
type = MimeType.FONT_BYTESTREAM;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Response.builder()
|
||||||
|
.setMimeType(type)
|
||||||
|
.setContent(files.getCustomizableResourceOrDefault(fileName).asBytes())
|
||||||
|
.build();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return notFound404("Font File not found from jar: " + fileName + ", " + e.toString());
|
||||||
}
|
}
|
||||||
return new ByteResponse(type, FileResponse.format(fileName), files);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Response redirectResponse(String location) {
|
public Response redirectResponse(String location) {
|
||||||
@ -313,12 +339,6 @@ public class ResponseFactory {
|
|||||||
return stackTrace;
|
return stackTrace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated
|
|
||||||
public ErrorResponse forbidden403_old() {
|
|
||||||
return forbidden403_old("Your user is not authorized to view this page.<br>"
|
|
||||||
+ "If you believe this is an error contact staff to change your access level.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public Response forbidden403() {
|
public Response forbidden403() {
|
||||||
return forbidden403("Your user is not authorized to view this page.<br>"
|
return forbidden403("Your user is not authorized to view this page.<br>"
|
||||||
+ "If you believe this is an error contact staff to change your access level.");
|
+ "If you believe this is an error contact staff to change your access level.");
|
||||||
@ -336,24 +356,6 @@ public class ResponseFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated
|
|
||||||
public ErrorResponse forbidden403_old(String message) {
|
|
||||||
try {
|
|
||||||
return new ForbiddenResponse(message, versionCheckSystem, files);
|
|
||||||
} catch (IOException e) {
|
|
||||||
return internalErrorResponse_old(e, "Failed to generate ForbiddenResponse");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated
|
|
||||||
public ErrorResponse basicAuth_old() {
|
|
||||||
try {
|
|
||||||
return PromptAuthorizationResponse.getBasicAuthResponse(versionCheckSystem, files);
|
|
||||||
} catch (IOException e) {
|
|
||||||
return internalErrorResponse_old(e, "Failed to generate PromptAuthorizationResponse");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Response basicAuth() {
|
public Response basicAuth() {
|
||||||
try {
|
try {
|
||||||
String tips = "<br>- Ensure you have registered a user with <b>/plan register</b><br>"
|
String tips = "<br>- Ensure you have registered a user with <b>/plan register</b><br>"
|
||||||
|
Loading…
Reference in New Issue
Block a user