Implemented ResourceSvc

- Customizable resources
- Snippets
- Fixed issue with a web resource being fetched on enable (favicon in ResponseResolver)
- Fixed some issues with Config#addNode used in an orElse block of Optional
- Deprecated PlanFiles#getCustomizableResourceOrDefault
This commit is contained in:
Risto Lahtela 2020-03-19 18:01:52 +02:00
parent f2ba301880
commit dae96ef53d
11 changed files with 367 additions and 32 deletions

View File

@ -16,8 +16,9 @@
*/
package com.djrapitops.plan.delivery.web;
import com.djrapitops.plan.delivery.web.resource.Resource;
import com.djrapitops.plan.delivery.web.resource.WebResource;
import java.util.Optional;
import java.util.function.Supplier;
/**
@ -27,15 +28,20 @@ import java.util.function.Supplier;
*/
public interface ResourceService {
static ResourceService getInstance() {
return Optional.ofNullable(ResourceService.Holder.service)
.orElseThrow(() -> new IllegalStateException("ResourceService has not been initialised yet."));
}
/**
* Make one of your web resources customizable by user or Plan API.
*
* @param pluginName Name of your plugin (for config purposes)
* @param fileName Name of the file (for customization)
* @param source Supplier to use to get the original resource.
* @param source Supplier to use to get the original resource, it is assumed that any text based files are encoded in UTF-8.
* @return Resource of the customized file.
*/
Resource getResource(String pluginName, String fileName, Supplier<Resource> source);
WebResource getResource(String pluginName, String fileName, Supplier<WebResource> source);
/**
* Add javascript to load in an existing html resource.
@ -81,4 +87,16 @@ public interface ResourceService {
*/
BODY_END
}
class Holder {
static ResourceService service;
private Holder() {
/* Static variable holder */
}
static void set(ResourceService service) {
ResourceService.Holder.service = service;
}
}
}

View File

@ -26,18 +26,41 @@ import java.nio.charset.StandardCharsets;
* Represents a customizable resource.
* <p>
* You can use the create methods for simple resources when using {@link com.djrapitops.plan.delivery.web.ResourceService}.
* <p>
* It is assumed that any text based files are encoded in UTF-8.
*
* @author Rsl1122
*/
public interface Resource {
public interface WebResource {
static Resource create(byte[] content) {
/**
* Create a new WebResource from byte array.
*
* @param content Bytes of the resource.
* @return WebResource.
*/
static WebResource create(byte[] content) {
return new ByteResource(content);
}
static Resource create(String utf8String) {
/**
* Create a new WebResource from an UTF-8 String.
*
* @param utf8String String in UTF-8 encoding.
* @return WebResource.
*/
static WebResource create(String utf8String) {
return new ByteResource(utf8String.getBytes(StandardCharsets.UTF_8));
}
static Resource create(InputStream in) throws IOException {
/**
* Creates a new WebResource from an InputStream.
*
* @param in InputStream for the resource, closed after inside the method.
* @return WebResource.
* @throws IOException If the stream can not be read.
*/
static WebResource create(InputStream in) throws IOException {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
int read;
byte[] bytes = new byte[1024];
@ -53,11 +76,16 @@ public interface Resource {
byte[] asBytes();
/**
* Return the resource as a UTF-8 String.
*
* @return The resource in UTF-8.
*/
String asString();
InputStream asStream();
final class ByteResource implements Resource {
final class ByteResource implements WebResource {
private final byte[] content;
public ByteResource(byte[] content) {

View File

@ -17,10 +17,10 @@
package com.djrapitops.plan;
import com.djrapitops.plan.api.PlanAPI;
import com.djrapitops.plan.capability.CapabilitySvc;
import com.djrapitops.plan.delivery.DeliveryUtilities;
import com.djrapitops.plan.delivery.export.ExportSystem;
import com.djrapitops.plan.delivery.web.ResolverSvc;
import com.djrapitops.plan.delivery.web.ResourceSvc;
import com.djrapitops.plan.delivery.webserver.NonProxyWebserverDisableChecker;
import com.djrapitops.plan.delivery.webserver.WebServer;
import com.djrapitops.plan.delivery.webserver.WebServerSystem;
@ -79,6 +79,7 @@ public class PlanSystem implements SubSystem {
private final ExportSystem exportSystem;
private final DeliveryUtilities deliveryUtilities;
private final ResolverSvc resolverService;
private final ResourceSvc resourceService;
private final ExtensionSvc extensionService;
private final QuerySvc queryService;
private final SettingsSvc settingsService;
@ -103,6 +104,7 @@ public class PlanSystem implements SubSystem {
ExportSystem exportSystem,
DeliveryUtilities deliveryUtilities,
ResolverSvc resolverService,
ResourceSvc resourceService,
ExtensionSvc extensionService,
QuerySvc queryService,
SettingsSvc settingsService,
@ -126,6 +128,7 @@ public class PlanSystem implements SubSystem {
this.exportSystem = exportSystem;
this.deliveryUtilities = deliveryUtilities;
this.resolverService = resolverService;
this.resourceService = resourceService;
this.extensionService = extensionService;
this.queryService = queryService;
this.settingsService = settingsService;
@ -155,10 +158,9 @@ public class PlanSystem implements SubSystem {
@Override
public void enable() throws EnableException {
CapabilitySvc.initialize();
extensionService.register();
resolverService.register();
resourceService.register();
settingsService.register();
queryService.register();

View File

@ -19,6 +19,7 @@ package com.djrapitops.plan.delivery.rendering.pages;
import com.djrapitops.plan.delivery.domain.container.PlayerContainer;
import com.djrapitops.plan.delivery.formatting.Formatters;
import com.djrapitops.plan.delivery.rendering.html.icon.Icon;
import com.djrapitops.plan.delivery.web.ResourceService;
import com.djrapitops.plan.delivery.web.resolver.exception.NotFoundException;
import com.djrapitops.plan.extension.implementation.results.ExtensionData;
import com.djrapitops.plan.extension.implementation.storage.queries.ExtensionPlayerDataQuery;
@ -41,6 +42,7 @@ import dagger.Lazy;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.*;
/**
@ -92,14 +94,14 @@ public class PageFactory {
public DebugPage debugPage() throws IOException {
return new DebugPage(
getResource("web/error.html"),
getResource("error.html"),
dbSystem.get().getDatabase(), serverInfo.get(), formatters.get(), versionChecker.get(),
debugLogger.get(), timings.get(), errorHandler.get()
);
}
public PlayersPage playersPage() throws IOException {
return new PlayersPage(getResource("web/players.html"), versionChecker.get(),
return new PlayersPage(getResource("players.html"), versionChecker.get(),
config.get(), locale.get(), theme.get(), serverInfo.get());
}
@ -115,7 +117,7 @@ public class PageFactory {
Server server = dbSystem.get().getDatabase().query(ServerQueries.fetchServerMatchingIdentifier(serverUUID))
.orElseThrow(() -> new NotFoundException("Server not found in the database"));
return new ServerPage(
getResource("web/server.html"),
getResource("server.html"),
server,
config.get(),
theme.get(),
@ -131,7 +133,7 @@ public class PageFactory {
Database db = dbSystem.get().getDatabase();
PlayerContainer player = db.query(ContainerFetchQueries.fetchPlayerContainer(playerUUID));
return new PlayerPage(
getResource("web/player.html"), player,
getResource("player.html"), player,
versionChecker.get(),
config.get(), this, theme.get(), locale.get(),
formatters.get(), serverInfo.get()
@ -172,7 +174,7 @@ public class PageFactory {
}
public NetworkPage networkPage() throws IOException {
return new NetworkPage(getResource("web/network.html"),
return new NetworkPage(getResource("network.html"),
dbSystem.get(),
versionChecker.get(),
config.get(), theme.get(), locale.get(),
@ -182,7 +184,7 @@ public class PageFactory {
public Page internalErrorPage(String message, Throwable error) {
try {
return new InternalErrorPage(
getResource("web/error.html"), message, error,
getResource("error.html"), message, error,
versionChecker.get());
} catch (IOException noParse) {
return () -> "Error occurred: " + error.toString() +
@ -193,17 +195,23 @@ public class PageFactory {
public Page errorPage(String title, String error) throws IOException {
return new ErrorMessagePage(
getResource("web/error.html"), title, error,
getResource("error.html"), title, error,
versionChecker.get(), locale.get(), theme.get());
}
public Page errorPage(Icon icon, String title, String error) throws IOException {
return new ErrorMessagePage(
getResource("web/error.html"), icon, title, error,
getResource("error.html"), icon, title, error,
locale.get(), theme.get(), versionChecker.get());
}
public String getResource(String name) throws IOException {
return files.get().getCustomizableResourceOrDefault(name).asString();
try {
return ResourceService.getInstance().getResource("Plan", name,
() -> files.get().getResourceFromJar("web/" + name).asWebResource()
).asString();
} catch (UncheckedIOException readFail) {
throw readFail.getCause();
}
}
}

View File

@ -0,0 +1,211 @@
/*
* 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.web;
import com.djrapitops.plan.delivery.web.resource.WebResource;
import com.djrapitops.plan.settings.config.PlanConfig;
import com.djrapitops.plan.settings.config.ResourceSettings;
import com.djrapitops.plan.storage.file.PlanFiles;
import com.djrapitops.plan.storage.file.Resource;
import com.djrapitops.plugin.logging.L;
import com.djrapitops.plugin.logging.error.ErrorHandler;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.TextStringBuilder;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.OpenOption;
import java.nio.file.StandardOpenOption;
import java.util.*;
import java.util.function.Supplier;
/**
* ResourceService implementation.
*
* @author Rsl1122
*/
@Singleton
public class ResourceSvc implements ResourceService {
public final Set<Snippet> snippets;
private final PlanFiles files;
private final ResourceSettings resourceSettings;
private final ErrorHandler errorHandler;
@Inject
public ResourceSvc(
PlanFiles files,
PlanConfig config,
ErrorHandler errorHandler
) {
this.files = files;
this.resourceSettings = config.getResourceSettings();
this.errorHandler = errorHandler;
this.snippets = new HashSet<>();
}
public void register() {
Holder.set(this);
}
@Override
public WebResource getResource(String pluginName, String fileName, Supplier<WebResource> source) {
return applySnippets(pluginName, fileName, getTheResource(pluginName, fileName, source));
}
private WebResource applySnippets(String pluginName, String fileName, WebResource resource) {
Map<Position, StringBuilder> byPosition = calculateSnippets(pluginName, fileName);
if (byPosition.isEmpty()) return resource;
String html = applySnippets(resource, byPosition);
return WebResource.create(html);
}
private String applySnippets(WebResource resource, Map<Position, StringBuilder> byPosition) {
String html = resource.asString();
if (html == null) {
return "Error: Given resource did not support WebResource#asString method properly and returned 'null'";
}
StringBuilder toHead = byPosition.get(Position.HEAD);
if (toHead != null) {
html = StringUtils.replaceOnce(html, "</head>", toHead.append("</head>").toString());
}
StringBuilder toBody = byPosition.get(Position.BODY);
if (toBody != null) {
if (StringUtils.contains(html, "<!-- End of Page Wrapper -->")) {
html = StringUtils.replaceOnce(html, "<!-- End of Page Wrapper -->", toBody.toString());
} else {
html = StringUtils.replaceOnce(html, "<body>", toBody.append("<body>").toString());
}
}
StringBuilder toBodyEnd = byPosition.get(Position.BODY_END);
if (toBodyEnd != null) {
html = StringUtils.replaceOnce(html, "<\body>", toBodyEnd.append("<\body>").toString());
}
return html;
}
private Map<Position, StringBuilder> calculateSnippets(String pluginName, String fileName) {
Map<Position, StringBuilder> byPosition = new EnumMap<>(Position.class);
for (Snippet snippet : snippets) {
if (snippet.matches(pluginName, fileName)) {
byPosition.computeIfAbsent(snippet.position, k -> new StringBuilder()).append(snippet.content);
}
}
return byPosition;
}
public WebResource getTheResource(String pluginName, String fileName, Supplier<WebResource> source) {
try {
if (resourceSettings.shouldBeCustomized(pluginName, fileName)) {
return getOrWriteCustomized(fileName, source);
}
} catch (IOException e) {
errorHandler.log(L.WARN, getClass(), e.getCause());
}
// Return original by default
return source.get();
}
public WebResource getOrWriteCustomized(String fileName, Supplier<WebResource> source) throws IOException {
Optional<Resource> customizedResource = files.getCustomizableResource(fileName);
if (customizedResource.isPresent()) {
return readCustomized(customizedResource.get());
} else {
return writeCustomized(fileName, source);
}
}
public WebResource readCustomized(Resource customizedResource) throws IOException {
try {
return customizedResource.asWebResource();
} catch (UncheckedIOException readFail) {
throw readFail.getCause();
}
}
public WebResource writeCustomized(String fileName, Supplier<WebResource> source) throws IOException {
WebResource original = source.get();
byte[] bytes = original.asBytes();
OpenOption[] overwrite = {StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE};
Files.write(files.getCustomizationDirectory().resolve(fileName), bytes, overwrite);
return original;
}
@Override
public void addScriptsToResource(String pluginName, String fileName, Position position, String... jsSrcs) {
if (!fileName.endsWith(".html")) {
throw new IllegalArgumentException("'" + fileName + "' is not a .html file! Only html files can be added to.");
}
String snippet = new TextStringBuilder("<script src=\"")
.appendWithSeparators(jsSrcs, "\"></script><script src=\"")
.append("\"></script>").build();
snippets.add(new Snippet(pluginName, fileName, position, snippet));
}
@Override
public void addStylesToResource(String pluginName, String fileName, Position position, String... cssSrcs) {
if (!fileName.endsWith(".html")) {
throw new IllegalArgumentException("'" + fileName + "' is not a .html file! Only html files can be added to.");
}
String snippet = new TextStringBuilder("<link href=\"")
.appendWithSeparators(cssSrcs, "\" ref=\"stylesheet\"></link><link href=\"")
.append("\" ref=\"stylesheet\"></link>").build();
snippets.add(new Snippet(pluginName, fileName, position, snippet));
}
private static class Snippet {
private final String pluginName;
private final String fileName;
private final Position position;
private final String content;
public Snippet(String pluginName, String fileName, Position position, String content) {
this.pluginName = pluginName;
this.fileName = fileName;
this.position = position;
this.content = content;
}
public boolean matches(String pluginName, String fileName) {
return pluginName.equals(this.pluginName) && fileName.equals(this.fileName);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Snippet snippet = (Snippet) o;
return Objects.equals(pluginName, snippet.pluginName) &&
Objects.equals(fileName, snippet.fileName) &&
position == snippet.position &&
Objects.equals(content, snippet.content);
}
@Override
public int hashCode() {
return Objects.hash(pluginName, fileName, position, content);
}
}
}

View File

@ -96,7 +96,7 @@ public class ResponseResolver {
resolverService.registerResolver(plugin, "/debug", debugPageResolver);
resolverService.registerResolver(plugin, "/players", playersPageResolver);
resolverService.registerResolver(plugin, "/player", playerPageResolver);
resolverService.registerResolver(plugin, "/favicon.ico", noAuthResolverFor(responseFactory.faviconResponse()));
resolverService.registerResolver(plugin, "/favicon.ico", (NoAuthResolver) request -> Optional.of(responseFactory.faviconResponse()));
resolverService.registerResolver(plugin, "/network", serverPageResolver);
resolverService.registerResolver(plugin, "/server", serverPageResolver);
resolverService.registerResolverForMatches(plugin, Pattern.compile("^/$"), rootPageResolver);
@ -105,10 +105,6 @@ public class ResponseResolver {
resolverService.registerResolver(plugin, "/v1", rootJSONResolver.getResolver());
}
public NoAuthResolver noAuthResolverFor(Response response) {
return request -> Optional.of(response);
}
public Response getResponse(RequestInternal request) {
try {
return tryToGetResponse(request);

View File

@ -44,6 +44,7 @@ public class PlanConfig extends Config {
private final PlanFiles files;
private final ExtensionSettings extensionSettings;
private final ResourceSettings resourceSettings;
private final WorldAliasSettings worldAliasSettings;
private final PluginLogger logger;
@ -53,12 +54,7 @@ public class PlanConfig extends Config {
WorldAliasSettings worldAliasSettings,
PluginLogger logger
) {
super(files.getConfigFile());
this.files = files;
this.extensionSettings = new ExtensionSettings(this);
this.worldAliasSettings = worldAliasSettings;
this.logger = logger;
this(files.getConfigFile(), files, worldAliasSettings, logger);
}
// For testing
@ -72,6 +68,7 @@ public class PlanConfig extends Config {
this.files = files;
this.extensionSettings = new ExtensionSettings(this);
this.resourceSettings = new ResourceSettings(this);
this.worldAliasSettings = worldAliasSettings;
this.logger = logger;
}
@ -142,6 +139,10 @@ public class PlanConfig extends Config {
return extensionSettings;
}
public ResourceSettings getResourceSettings() {
return resourceSettings;
}
public WorldAliasSettings getWorldAliasSettings() {
return worldAliasSettings;
}

View File

@ -0,0 +1,48 @@
/*
* 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.settings.config;
import java.io.IOException;
import java.io.UncheckedIOException;
public class ResourceSettings {
private final PlanConfig config;
public ResourceSettings(
PlanConfig config
) {
this.config = config;
}
public boolean shouldBeCustomized(String plugin, String fileName) {
ConfigNode fileCustomization = config.getNode("Customized_files").orElseGet(() -> config.addNode("Customized_files"));
ConfigNode pluginCustomization = fileCustomization.getNode(plugin).orElseGet(() -> fileCustomization.addNode(plugin));
if (pluginCustomization.contains(fileName)) {
return pluginCustomization.getBoolean(fileName);
} else {
pluginCustomization.set(fileName, false);
try {
pluginCustomization.save();
} catch (IOException e) {
throw new UncheckedIOException("Could not save config.yml: " + e.getMessage(), e);
}
return false;
}
}
}

View File

@ -46,7 +46,7 @@ public class DisplaySettings {
public static final Setting<ConfigNode> WORLD_ALIASES = new Setting<ConfigNode>("World_aliases", ConfigNode.class) {
@Override
public ConfigNode getValueFrom(ConfigNode node) {
return node.getNode(path).orElse(node.addNode(path));
return node.getNode(path).orElseGet(() -> node.addNode(path));
}
};

View File

@ -57,6 +57,10 @@ public class PlanFiles implements SubSystem {
return dataFolder.toPath();
}
public Path getCustomizationDirectory() {
return dataFolder.toPath().resolve("web");
}
public File getLogsFolder() {
File folder = getFileFromPluginFolder("logs");
folder.mkdirs();
@ -119,7 +123,9 @@ public class PlanFiles implements SubSystem {
*
* @param resourceName Path to the file inside the plugin folder.
* @return a {@link Resource} for accessing the resource, either from the plugin folder or jar.
* @deprecated Use {@link PlanFiles#getCustomizableResource(String)} instead.
*/
@Deprecated
public Resource getCustomizableResourceOrDefault(String resourceName) {
return ResourceCache.getOrCache(resourceName, () ->
attemptToFind(resourceName).map(file -> (Resource) new FileResource(resourceName, file))
@ -144,4 +150,8 @@ public class PlanFiles implements SubSystem {
}
return Optional.empty();
}
public Optional<Resource> getCustomizableResource(String resourceName) {
return attemptToFind(resourceName).map(found -> new FileResource(resourceName, found));
}
}

View File

@ -16,10 +16,12 @@
*/
package com.djrapitops.plan.storage.file;
import com.djrapitops.plan.delivery.web.resource.WebResource;
import org.apache.commons.lang3.StringUtils;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.List;
/**
@ -62,6 +64,17 @@ public interface Resource {
*/
String asString() throws IOException;
/**
* @throws UncheckedIOException if fails to read the file
*/
default WebResource asWebResource() {
try {
return WebResource.create(asInputStream());
} catch (IOException e) {
throw new UncheckedIOException("Failed to read '" + getResourceName() + "'", e);
}
}
/**
* Check if a resource is a text based file.
*