diff --git a/Plan/api/src/main/java/com/djrapitops/plan/delivery/web/resource/WebResource.java b/Plan/api/src/main/java/com/djrapitops/plan/delivery/web/resource/WebResource.java index 2b0057ceb..4a87013e5 100644 --- a/Plan/api/src/main/java/com/djrapitops/plan/delivery/web/resource/WebResource.java +++ b/Plan/api/src/main/java/com/djrapitops/plan/delivery/web/resource/WebResource.java @@ -16,11 +16,10 @@ */ package com.djrapitops.plan.delivery.web.resource; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; +import java.io.*; import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.function.Supplier; /** * Represents a customizable resource. @@ -61,6 +60,18 @@ public interface WebResource { * @throws IOException If the stream can not be read. */ static WebResource create(InputStream in) throws IOException { + return create(in, null); + } + + /** + * Creates a new WebResource from an InputStream. + * + * @param in InputStream for the resource, closed after inside the method. + * @param lastModified Epoch millisecond the resource was last modified + * @return WebResource. + * @throws IOException If the stream can not be read. + */ + static WebResource create(InputStream in, Long lastModified) throws IOException { try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { int read; byte[] bytes = new byte[1024]; @@ -68,12 +79,36 @@ public interface WebResource { out.write(bytes, 0, read); } - return new ByteResource(out.toByteArray()); + return new ByteResource(out.toByteArray(), lastModified); } finally { in.close(); } } + /** + * Create a lazy WebResource that only reads contents if necessary. + * + * @param in Supplier for InputStream, a lazy method that reads input when necessary. + * @param lastModified Last modified date for the resource. + * @return WebResource. + */ + static WebResource create(Supplier in, Long lastModified) { + return new LazyWebResource(in, () -> { + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + InputStream input = in.get()) { + int read; + byte[] bytes = new byte[1024]; + while ((read = input.read(bytes)) != -1) { + out.write(bytes, 0, read); + } + + return out.toByteArray(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }, lastModified); + } + byte[] asBytes(); /** @@ -85,11 +120,21 @@ public interface WebResource { InputStream asStream(); + default Optional getLastModified() { + return Optional.empty(); + } + final class ByteResource implements WebResource { private final byte[] content; + private final Long lastModified; public ByteResource(byte[] content) { + this(content, null); + } + + public ByteResource(byte[] content, Long lastModified) { this.content = content; + this.lastModified = lastModified; } @Override @@ -106,5 +151,42 @@ public interface WebResource { public InputStream asStream() { return new ByteArrayInputStream(content); } + + @Override + public Optional getLastModified() { + return Optional.ofNullable(lastModified); + } + } + + final class LazyWebResource implements WebResource { + private final Supplier inputStreamSupplier; + private final Supplier contentSupplier; + private final Long lastModified; + + public LazyWebResource(Supplier inputStreamSupplier, Supplier contentSupplier, Long lastModified) { + this.inputStreamSupplier = inputStreamSupplier; + this.contentSupplier = contentSupplier; + this.lastModified = lastModified; + } + + @Override + public byte[] asBytes() { + return contentSupplier.get(); + } + + @Override + public String asString() { + return new String(asBytes(), StandardCharsets.UTF_8); + } + + @Override + public InputStream asStream() { + return inputStreamSupplier.get(); + } + + @Override + public Optional getLastModified() { + return Optional.ofNullable(lastModified); + } } } \ No newline at end of file diff --git a/Plan/bukkit/src/main/java/com/djrapitops/plan/addons/placeholderapi/PlanPlaceholderExtension.java b/Plan/bukkit/src/main/java/com/djrapitops/plan/addons/placeholderapi/PlanPlaceholderExtension.java index d0ba4d176..b50feda7c 100644 --- a/Plan/bukkit/src/main/java/com/djrapitops/plan/addons/placeholderapi/PlanPlaceholderExtension.java +++ b/Plan/bukkit/src/main/java/com/djrapitops/plan/addons/placeholderapi/PlanPlaceholderExtension.java @@ -96,7 +96,9 @@ public class PlanPlaceholderExtension extends PlaceholderExpansion { if ("Server thread".equalsIgnoreCase(Thread.currentThread().getName())) { return getCached(params, uuid); } - return getPlaceholderValue(params, uuid); + + return Optional.ofNullable(getCached(params, uuid)) + .orElseGet(() -> getPlaceholderValue(params, uuid)); } catch (IllegalStateException e) { if ("zip file closed".equals(e.getMessage())) { return null; // Plan is disabled. diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/json/PlayerJSONCreator.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/json/PlayerJSONCreator.java index b181a7f55..0c7c37e4f 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/json/PlayerJSONCreator.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/json/PlayerJSONCreator.java @@ -46,6 +46,7 @@ import com.djrapitops.plan.storage.database.DBSystem; import com.djrapitops.plan.storage.database.Database; import com.djrapitops.plan.storage.database.queries.containers.PlayerContainerQuery; import com.djrapitops.plan.storage.database.queries.objects.ServerQueries; +import com.djrapitops.plan.storage.database.queries.objects.SessionQueries; import com.djrapitops.plan.utilities.comparators.DateHolderRecentComparator; import com.djrapitops.plan.utilities.java.Lists; import com.djrapitops.plan.utilities.java.Maps; @@ -91,6 +92,10 @@ public class PlayerJSONCreator { this.graphs = graphs; } + public long getLastSeen(UUID playerUUID) { + return dbSystem.getDatabase().query(SessionQueries.lastSeen(playerUUID)); + } + public Map createJSONAsMap(UUID playerUUID) { Database db = dbSystem.getDatabase(); @@ -226,6 +231,7 @@ public class PlayerJSONCreator { info.put("best_ping", bestPing != -1.0 ? bestPing + " ms" : unavailable); info.put("registered", player.getValue(PlayerKeys.REGISTERED).map(year).orElse("-")); info.put("last_seen", player.getValue(PlayerKeys.LAST_SEEN).map(year).orElse("-")); + info.put("last_seen_raw_value", player.getValue(PlayerKeys.LAST_SEEN).orElse(0L)); return info; } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/LoginPage.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/LoginPage.java index 4302f93a8..f16ebf0e4 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/LoginPage.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/LoginPage.java @@ -17,6 +17,7 @@ package com.djrapitops.plan.delivery.rendering.pages; import com.djrapitops.plan.delivery.formatting.PlaceholderReplacer; +import com.djrapitops.plan.delivery.web.resource.WebResource; import com.djrapitops.plan.identification.ServerInfo; import com.djrapitops.plan.settings.locale.Locale; import com.djrapitops.plan.settings.theme.Theme; @@ -30,7 +31,7 @@ import com.djrapitops.plan.version.VersionChecker; */ public class LoginPage implements Page { - private final String template; + private final WebResource template; private final ServerInfo serverInfo; private final Locale locale; private final Theme theme; @@ -38,7 +39,7 @@ public class LoginPage implements Page { private final VersionChecker versionChecker; LoginPage( - String htmlTemplate, + WebResource htmlTemplate, ServerInfo serverInfo, Locale locale, Theme theme, @@ -51,12 +52,17 @@ public class LoginPage implements Page { this.versionChecker = versionChecker; } + @Override + public long lastModified() { + return template.getLastModified().orElseGet(System::currentTimeMillis); + } + @Override public String toHtml() { PlaceholderReplacer placeholders = new PlaceholderReplacer(); placeholders.put("command", getCommand()); placeholders.put("version", versionChecker.getCurrentVersion()); - return UnaryChain.of(template) + return UnaryChain.of(template.asString()) .chain(theme::replaceThemeColors) .chain(placeholders::apply) .chain(locale::replaceLanguageInHtml) diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/Page.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/Page.java index c6ae088fa..8385c7b1b 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/Page.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/Page.java @@ -23,4 +23,8 @@ package com.djrapitops.plan.delivery.rendering.pages; */ public interface Page { String toHtml(); + + default long lastModified() { + return System.currentTimeMillis(); + } } \ No newline at end of file diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/PageFactory.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/PageFactory.java index 6fcac8132..1b52e66b6 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/PageFactory.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/PageFactory.java @@ -22,6 +22,7 @@ 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.delivery.web.resource.WebResource; import com.djrapitops.plan.delivery.webserver.Addresses; import com.djrapitops.plan.delivery.webserver.cache.JSONStorage; import com.djrapitops.plan.extension.implementation.results.ExtensionData; @@ -41,7 +42,6 @@ import com.djrapitops.plan.storage.file.PlanFiles; import com.djrapitops.plan.utilities.dev.Untrusted; import com.djrapitops.plan.version.VersionChecker; import dagger.Lazy; -import org.apache.commons.lang3.StringUtils; import javax.inject.Inject; import javax.inject.Singleton; @@ -101,15 +101,12 @@ public class PageFactory { return reactPage(); } - return new PlayersPage(getResource("players.html"), versionChecker.get(), + return new PlayersPage(getResourceAsString("players.html"), versionChecker.get(), config.get(), theme.get(), serverInfo.get()); } public Page reactPage() throws IOException { - String reactHtml = StringUtils.replace( - getResource("index.html"), - "/static", getBasePath() + "/static"); - return () -> reactHtml; + return new ReactPage(getBasePath(), getResource("index.html")); } private String getBasePath() { @@ -135,7 +132,7 @@ public class PageFactory { } return new ServerPage( - getResource("server.html"), + getResourceAsString("server.html"), server, config.get(), theme.get(), @@ -158,7 +155,7 @@ public class PageFactory { } return new PlayerPage( - getResource("player.html"), player, + getResourceAsString("player.html"), player, versionChecker.get(), config.get(), this, @@ -207,7 +204,7 @@ public class PageFactory { return reactPage(); } - return new NetworkPage(getResource("network.html"), + return new NetworkPage(getResourceAsString("network.html"), dbSystem.get(), versionChecker.get(), config.get(), @@ -223,7 +220,7 @@ public class PageFactory { public Page internalErrorPage(String message, @Untrusted Throwable error) { try { return new InternalErrorPage( - getResource("error.html"), message, error, + getResourceAsString("error.html"), message, error, versionChecker.get()); } catch (IOException noParse) { return () -> "Error occurred: " + error.toString() + @@ -234,20 +231,24 @@ public class PageFactory { public Page errorPage(String title, String error) throws IOException { return new ErrorMessagePage( - getResource("error.html"), title, error, + getResourceAsString("error.html"), title, error, versionChecker.get(), theme.get()); } public Page errorPage(Icon icon, String title, String error) throws IOException { return new ErrorMessagePage( - getResource("error.html"), icon, title, error, theme.get(), versionChecker.get()); + getResourceAsString("error.html"), icon, title, error, theme.get(), versionChecker.get()); } - public String getResource(String name) throws IOException { + public String getResourceAsString(String name) throws IOException { + return getResource(name).asString(); + } + + public WebResource getResource(String name) throws IOException { try { return ResourceService.getInstance().getResource("Plan", name, () -> files.get().getResourceFromJar("web/" + name).asWebResource() - ).asString(); + ); } catch (UncheckedIOException readFail) { throw readFail.getCause(); } @@ -274,7 +275,7 @@ public class PageFactory { return reactPage(); } return new QueryPage( - getResource("query.html"), + getResourceAsString("query.html"), locale.get(), theme.get(), versionChecker.get() ); } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/ReactPage.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/ReactPage.java new file mode 100644 index 000000000..bab588e31 --- /dev/null +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/rendering/pages/ReactPage.java @@ -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 . + */ +package com.djrapitops.plan.delivery.rendering.pages; + +import com.djrapitops.plan.delivery.web.resource.WebResource; +import org.apache.commons.lang3.StringUtils; + +/** + * Represents React index.html. + * + * @author AuroraLS3 + */ +public class ReactPage implements Page { + + private final String basePath; + private final WebResource reactHtml; + + public ReactPage(String basePath, WebResource reactHtml) { + this.basePath = basePath; + this.reactHtml = reactHtml; + } + + @Override + public String toHtml() { + return StringUtils.replace( + reactHtml.asString(), + "/static", basePath + "/static"); + } + + @Override + public long lastModified() { + return reactHtml.getLastModified().orElseGet(System::currentTimeMillis); + } +} diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/CacheStrategy.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/CacheStrategy.java new file mode 100644 index 000000000..74b26152f --- /dev/null +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/CacheStrategy.java @@ -0,0 +1,32 @@ +/* + * 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 . + */ +package com.djrapitops.plan.delivery.webserver; + +/** + * @author AuroraLS3 + */ +public class CacheStrategy { + + public static final String CACHE_IN_BROWSER = "max-age: 2592000"; + public static final String CHECK_ETAG = "no-cache"; + public static final String CHECK_ETAG_USER_SPECIFIC = "no-cache, private"; + + private CacheStrategy() { + // Static variable class + } + +} diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java index ef64635e1..6c786b8c4 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java @@ -17,6 +17,8 @@ package com.djrapitops.plan.delivery.webserver; import com.djrapitops.plan.delivery.domain.container.PlayerContainer; +import com.djrapitops.plan.delivery.formatting.Formatter; +import com.djrapitops.plan.delivery.formatting.Formatters; import com.djrapitops.plan.delivery.rendering.html.icon.Family; import com.djrapitops.plan.delivery.rendering.html.icon.Icon; import com.djrapitops.plan.delivery.rendering.pages.Page; @@ -24,10 +26,13 @@ import com.djrapitops.plan.delivery.rendering.pages.PageFactory; import com.djrapitops.plan.delivery.web.ResourceService; import com.djrapitops.plan.delivery.web.resolver.MimeType; import com.djrapitops.plan.delivery.web.resolver.Response; +import com.djrapitops.plan.delivery.web.resolver.ResponseBuilder; import com.djrapitops.plan.delivery.web.resolver.exception.NotFoundException; +import com.djrapitops.plan.delivery.web.resolver.request.Request; import com.djrapitops.plan.delivery.web.resource.WebResource; import com.djrapitops.plan.delivery.webserver.auth.FailReason; import com.djrapitops.plan.exceptions.WebUserAuthException; +import com.djrapitops.plan.identification.Identifiers; import com.djrapitops.plan.identification.ServerUUID; import com.djrapitops.plan.settings.locale.Locale; import com.djrapitops.plan.settings.locale.lang.ErrorPageLang; @@ -42,6 +47,7 @@ import com.djrapitops.plan.utilities.java.UnaryChain; import dagger.Lazy; import org.apache.commons.lang3.StringUtils; import org.apache.commons.text.StringEscapeUtils; +import org.eclipse.jetty.http.HttpHeader; import javax.inject.Inject; import javax.inject.Singleton; @@ -51,6 +57,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.function.Function; /** * Factory for creating different {@link Response} objects. @@ -66,6 +73,7 @@ public class ResponseFactory { private final DBSystem dbSystem; private final Theme theme; private final Lazy addresses; + private final Formatter httpLastModifiedFormatter; @Inject public ResponseFactory( @@ -73,6 +81,7 @@ public class ResponseFactory { PageFactory pageFactory, Locale locale, DBSystem dbSystem, + Formatters formatters, Theme theme, Lazy addresses ) { @@ -82,6 +91,8 @@ public class ResponseFactory { this.dbSystem = dbSystem; this.theme = theme; this.addresses = addresses; + + httpLastModifiedFormatter = formatters.httpLastModifiedLong(); } public WebResource getResource(@Untrusted String resourceName) { @@ -89,10 +100,27 @@ public class ResponseFactory { () -> files.getResourceFromJar("web/" + resourceName).asWebResource()); } - private Response forPage(Page page) { + private static Response browserCachedNotChangedResponse() { + return Response.builder() + .setStatus(304) + .setContent(new byte[0]) + .build(); + } + + private Response forPage(@Untrusted Request request, Page page) { + long modified = page.lastModified(); + Optional etag = Identifiers.getEtag(request); + + if (etag.isPresent() && modified == etag.get()) { + return browserCachedNotChangedResponse(); + } + return Response.builder() .setMimeType(MimeType.HTML) .setContent(page.toHtml()) + .setHeader(HttpHeader.CACHE_CONTROL.asString(), CacheStrategy.CHECK_ETAG) + .setHeader(HttpHeader.LAST_MODIFIED.asString(), httpLastModifiedFormatter.apply(modified)) + .setHeader(HttpHeader.ETAG.asString(), modified) .build(); } @@ -104,11 +132,11 @@ public class ResponseFactory { .build(); } - public Response playersPageResponse() { + public Response playersPageResponse(@Untrusted Request request) { try { Optional error = checkDbClosedError(); if (error.isPresent()) return error.get(); - return forPage(pageFactory.playersPage()); + return forPage(request, pageFactory.playersPage()); } catch (IOException e) { return forInternalError(e, "Failed to generate players page"); } @@ -137,25 +165,35 @@ public class ResponseFactory { .build(); } + private Response getCachedOrNew(long modified, String fileName, Function newResponseFunction) { + WebResource resource = getResource(fileName); + Optional lastModified = resource.getLastModified(); + if (lastModified.isPresent() && modified == lastModified.get()) { + return browserCachedNotChangedResponse(); + } else { + return newResponseFunction.apply(fileName); + } + } + public Response internalErrorResponse(Throwable e, String cause) { return forInternalError(e, cause); } - public Response networkPageResponse() { + public Response networkPageResponse(@Untrusted Request request) { Optional error = checkDbClosedError(); if (error.isPresent()) return error.get(); try { - return forPage(pageFactory.networkPage()); + return forPage(request, pageFactory.networkPage()); } catch (IOException e) { return forInternalError(e, "Failed to generate network page"); } } - public Response serverPageResponse(ServerUUID serverUUID) { + public Response serverPageResponse(@Untrusted Request request, ServerUUID serverUUID) { Optional error = checkDbClosedError(); if (error.isPresent()) return error.get(); try { - return forPage(pageFactory.serverPage(serverUUID)); + return forPage(request, pageFactory.serverPage(serverUUID)); } catch (NotFoundException e) { return notFound404(e.getMessage()); } catch (IOException e) { @@ -171,23 +209,36 @@ public class ResponseFactory { .build(); } + public Response javaScriptResponse(long modified, @Untrusted String fileName) { + return getCachedOrNew(modified, fileName, this::javaScriptResponse); + } + public Response javaScriptResponse(@Untrusted String fileName) { try { - String content = UnaryChain.of(getResource(fileName).asString()) + WebResource resource = getResource(fileName); + String content = UnaryChain.of(resource.asString()) .chain(this::replaceMainAddressPlaceholder) .chain(theme::replaceThemeColors) - .chain(resource -> { - if (fileName.startsWith("vendor/") || fileName.startsWith("/vendor/")) {return resource;} - return locale.replaceLanguageInJavascript(resource); + .chain(contents -> { + if (fileName.startsWith("vendor/") || fileName.startsWith("/vendor/")) {return contents;} + return locale.replaceLanguageInJavascript(contents); }) - .chain(resource -> StringUtils.replace(resource, "n.p=\"/\"", + .chain(contents -> StringUtils.replace(contents, "n.p=\"/\"", "n.p=\"" + getBasePath() + "/\"")) .apply(); - return Response.builder() + ResponseBuilder responseBuilder = Response.builder() .setMimeType(MimeType.JS) .setContent(content) - .setStatus(200) - .build(); + .setStatus(200); + + if (fileName.contains("static")) { + resource.getLastModified().ifPresent(lastModified -> responseBuilder + // Can't cache main bundle in browser since base path might change + .setHeader(HttpHeader.CACHE_CONTROL.asString(), fileName.contains("main") ? CacheStrategy.CHECK_ETAG : CacheStrategy.CACHE_IN_BROWSER) + .setHeader(HttpHeader.LAST_MODIFIED.asString(), httpLastModifiedFormatter.apply(lastModified)) + .setHeader(HttpHeader.ETAG.asString(), lastModified)); + } + return responseBuilder.build(); } catch (UncheckedIOException e) { return notFound404("Javascript File not found"); } @@ -205,34 +256,64 @@ public class ResponseFactory { return StringUtils.replace(resource, "PLAN_BASE_ADDRESS", address); } + public Response cssResponse(long modified, @Untrusted String fileName) { + return getCachedOrNew(modified, fileName, this::cssResponse); + } + public Response cssResponse(@Untrusted String fileName) { try { - String content = UnaryChain.of(getResource(fileName).asString()) + WebResource resource = getResource(fileName); + String content = UnaryChain.of(resource.asString()) .chain(theme::replaceThemeColors) - .chain(resource -> StringUtils.replace(resource, "/static", getBasePath() + "/static")) + .chain(contents -> StringUtils.replace(contents, "/static", getBasePath() + "/static")) .apply(); - return Response.builder() + + ResponseBuilder responseBuilder = Response.builder() .setMimeType(MimeType.CSS) .setContent(content) - .setStatus(200) - .build(); + .setStatus(200); + + if (fileName.contains("static")) { + resource.getLastModified().ifPresent(lastModified -> responseBuilder + // Can't cache css bundles in browser since base path might change + .setHeader(HttpHeader.CACHE_CONTROL.asString(), CacheStrategy.CHECK_ETAG) + .setHeader(HttpHeader.LAST_MODIFIED.asString(), httpLastModifiedFormatter.apply(lastModified)) + .setHeader(HttpHeader.ETAG.asString(), lastModified)); + } + return responseBuilder.build(); } catch (UncheckedIOException e) { return notFound404("CSS File not found"); } } + public Response imageResponse(long modified, @Untrusted String fileName) { + return getCachedOrNew(modified, fileName, this::imageResponse); + } + public Response imageResponse(@Untrusted String fileName) { try { - return Response.builder() + WebResource resource = getResource(fileName); + ResponseBuilder responseBuilder = Response.builder() .setMimeType(MimeType.IMAGE) - .setContent(getResource(fileName)) - .setStatus(200) - .build(); + .setContent(resource) + .setStatus(200); + + if (fileName.contains("static")) { + resource.getLastModified().ifPresent(lastModified -> responseBuilder + .setHeader(HttpHeader.CACHE_CONTROL.asString(), CacheStrategy.CACHE_IN_BROWSER) + .setHeader(HttpHeader.LAST_MODIFIED.asString(), httpLastModifiedFormatter.apply(lastModified)) + .setHeader(HttpHeader.ETAG.asString(), lastModified)); + } + return responseBuilder.build(); } catch (UncheckedIOException e) { return notFound404("Image File not found"); } } + public Response fontResponse(long modified, @Untrusted String fileName) { + return getCachedOrNew(modified, fileName, this::fontResponse); + } + public Response fontResponse(@Untrusted String fileName) { String type; if (fileName.endsWith(".woff")) { @@ -247,10 +328,18 @@ public class ResponseFactory { type = MimeType.FONT_BYTESTREAM; } try { - return Response.builder() + WebResource resource = getResource(fileName); + ResponseBuilder responseBuilder = Response.builder() .setMimeType(type) - .setContent(getResource(fileName)) - .build(); + .setContent(resource); + + if (fileName.contains("static")) { + resource.getLastModified().ifPresent(lastModified -> responseBuilder + .setHeader(HttpHeader.CACHE_CONTROL.asString(), CacheStrategy.CACHE_IN_BROWSER) + .setHeader(HttpHeader.LAST_MODIFIED.asString(), httpLastModifiedFormatter.apply(lastModified)) + .setHeader(HttpHeader.ETAG.asString(), lastModified)); + } + return responseBuilder.build(); } catch (UncheckedIOException e) { return notFound404("Font File not found"); } @@ -273,9 +362,14 @@ public class ResponseFactory { public Response robotsResponse() { try { + WebResource resource = getResource("robots.txt"); + Long lastModified = resource.getLastModified().orElseGet(System::currentTimeMillis); return Response.builder() .setMimeType("text/plain") - .setContent(getResource("robots.txt")) + .setContent(resource) + .setHeader(HttpHeader.CACHE_CONTROL.asString(), CacheStrategy.CACHE_IN_BROWSER) + .setHeader(HttpHeader.LAST_MODIFIED.asString(), httpLastModifiedFormatter.apply(lastModified)) + .setHeader(HttpHeader.ETAG.asString(), lastModified) .build(); } catch (UncheckedIOException e) { return forInternalError(e, "Could not read robots.txt"); @@ -415,9 +509,9 @@ public class ResponseFactory { .build(); } - public Response playerPageResponse(UUID playerUUID) { + public Response playerPageResponse(@Untrusted Request request, UUID playerUUID) { try { - return forPage(pageFactory.playerPage(playerUUID)); + return forPage(request, pageFactory.playerPage(playerUUID)); } catch (IllegalStateException e) { return playerNotFound404(); } catch (IOException e) { @@ -425,33 +519,33 @@ public class ResponseFactory { } } - public Response loginPageResponse() { + public Response loginPageResponse(@Untrusted Request request) { try { - return forPage(pageFactory.loginPage()); + return forPage(request, pageFactory.loginPage()); } catch (IOException e) { return forInternalError(e, "Failed to generate player page"); } } - public Response registerPageResponse() { + public Response registerPageResponse(@Untrusted Request request) { try { - return forPage(pageFactory.registerPage()); + return forPage(request, pageFactory.registerPage()); } catch (IOException e) { return forInternalError(e, "Failed to generate player page"); } } - public Response queryPageResponse() { + public Response queryPageResponse(@Untrusted Request request) { try { - return forPage(pageFactory.queryPage()); + return forPage(request, pageFactory.queryPage()); } catch (IOException e) { return forInternalError(e, "Failed to generate query page"); } } - public Response errorsPageResponse() { + public Response errorsPageResponse(@Untrusted Request request) { try { - return forPage(pageFactory.errorsPage()); + return forPage(request, pageFactory.errorsPage()); } catch (IOException e) { return forInternalError(e, "Failed to generate errors page"); } @@ -468,12 +562,9 @@ public class ResponseFactory { } } - public Response reactPageResponse() { + public Response reactPageResponse(Request request) { try { - return Response.builder() - .setMimeType(MimeType.HTML) - .setContent(pageFactory.reactPage().toHtml()) - .build(); + return forPage(request, pageFactory.reactPage()); } catch (UncheckedIOException | IOException e) { return forInternalError(e, "Could not read index.html"); } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/AsyncJSONResolverService.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/AsyncJSONResolverService.java index 5a1623b97..3125dabb8 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/AsyncJSONResolverService.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/AsyncJSONResolverService.java @@ -16,6 +16,8 @@ */ package com.djrapitops.plan.delivery.webserver.cache; +import com.djrapitops.plan.delivery.formatting.Formatter; +import com.djrapitops.plan.delivery.formatting.Formatters; import com.djrapitops.plan.identification.ServerUUID; import com.djrapitops.plan.processing.Processing; import com.djrapitops.plan.settings.config.PlanConfig; @@ -46,10 +48,12 @@ public class AsyncJSONResolverService { private final Map> currentlyProcessing; private final Map previousUpdates; private final ReentrantLock accessLock; // Access lock prevents double processing same resource + private final Formatter httpLastModifiedFormatter; @Inject public AsyncJSONResolverService( PlanConfig config, + Formatters formatters, Processing processing, JSONStorage jsonStorage ) { @@ -60,6 +64,8 @@ public class AsyncJSONResolverService { currentlyProcessing = new ConcurrentHashMap<>(); previousUpdates = new ConcurrentHashMap<>(); accessLock = new ReentrantLock(); + + httpLastModifiedFormatter = formatters.httpLastModifiedLong(); } public JSONStorage.StoredJSON resolve( @@ -155,4 +161,8 @@ public class AsyncJSONResolverService { return created; }); } + + public Formatter getHttpLastModifiedFormatter() { + return httpLastModifiedFormatter; + } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/DataID.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/DataID.java index db1658f38..4c711ef3f 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/DataID.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/DataID.java @@ -55,6 +55,7 @@ public enum DataID { JOIN_ADDRESSES_BY_DAY; public String of(ServerUUID serverUUID) { + if (serverUUID == null) return name(); return name() + '-' + serverUUID; } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONFileStorage.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONFileStorage.java index adddecd7c..00c1e90f6 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONFileStorage.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONFileStorage.java @@ -129,9 +129,11 @@ public class JSONFileStorage implements JSONStorage { public Optional fetchJSON(String identifier) { File[] stored = jsonDirectory.toFile().listFiles(); if (stored == null) return Optional.empty(); + + String lookForStart = identifier + '-'; for (File file : stored) { String fileName = file.getName(); - if (fileName.endsWith(JSON_FILE_EXTENSION) && fileName.startsWith(identifier + '-')) { + if (fileName.endsWith(JSON_FILE_EXTENSION) && fileName.startsWith(lookForStart)) { return Optional.ofNullable(readStoredJSON(file)); } } @@ -179,10 +181,12 @@ public class JSONFileStorage implements JSONStorage { private Optional fetchJSONWithTimestamp(String identifier, long timestamp, BiPredicate timestampComparator) { File[] stored = jsonDirectory.toFile().listFiles(); if (stored == null) return Optional.empty(); + + String lookForStart = identifier + '-'; for (File file : stored) { try { String fileName = file.getName(); - if (fileName.endsWith(JSON_FILE_EXTENSION) && fileName.startsWith(identifier + '-')) { + if (fileName.endsWith(JSON_FILE_EXTENSION) && fileName.startsWith(lookForStart)) { Matcher timestampMatch = timestampRegex.matcher(fileName); if (timestampMatch.find() && timestampComparator.test(timestampMatch, timestamp)) { return Optional.ofNullable(readStoredJSON(file)); @@ -270,6 +274,28 @@ public class JSONFileStorage implements JSONStorage { }); } + @Override + public Optional getTimestamp(String identifier) { + File[] stored = jsonDirectory.toFile().listFiles(); + if (stored == null) return Optional.empty(); + + String lookForStart = identifier + '-'; + for (File file : stored) { + try { + String fileName = file.getName(); + if (fileName.endsWith(JSON_FILE_EXTENSION) && fileName.startsWith(lookForStart)) { + Matcher timestampMatch = timestampRegex.matcher(fileName); + if (timestampMatch.find()) { + return Optional.of(Long.parseLong(timestampMatch.group(1))); + } + } + } catch (NumberFormatException e) { + // Ignore this file, malformed timestamp + } + } + return Optional.empty(); + } + @Singleton public static class CleanTask extends TaskSystem.Task { private final PlanConfig config; diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONMemoryStorageShim.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONMemoryStorageShim.java index e46e36453..4f45d5a53 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONMemoryStorageShim.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONMemoryStorageShim.java @@ -120,6 +120,16 @@ public class JSONMemoryStorageShim implements JSONStorage { underlyingStorage.invalidateOlder(identifier, timestamp); } + @Override + public Optional getTimestamp(String identifier) { + for (TimestampedIdentifier key : getCache().asMap().keySet()) { + if (key.identifier.equalsIgnoreCase(identifier)) { + return Optional.of(key.timestamp); + } + } + return Optional.empty(); + } + static class TimestampedIdentifier { private final String identifier; private final long timestamp; diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONStorage.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONStorage.java index bb9d84086..7b9dddb39 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONStorage.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/cache/JSONStorage.java @@ -63,6 +63,8 @@ public interface JSONStorage extends SubSystem { void invalidateOlder(String identifier, long timestamp); + Optional getTimestamp(String identifier); + final class StoredJSON { public final String json; public final long timestamp; @@ -72,6 +74,14 @@ public interface JSONStorage extends SubSystem { this.timestamp = timestamp; } + public String getJson() { + return json; + } + + public long getTimestamp() { + return timestamp; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/JettyResponseSender.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/JettyResponseSender.java index bb45ff128..aaccfaca4 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/JettyResponseSender.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/JettyResponseSender.java @@ -46,7 +46,7 @@ public class JettyResponseSender { } public void send() throws IOException { - if ("HEAD".equals(servletRequest.getMethod()) || response.getCode() == 204) { + if ("HEAD".equals(servletRequest.getMethod()) || response.getCode() == 204 || response.getCode() == 304) { setResponseHeaders(); sendHeadResponse(); } else if (canGzip()) { @@ -117,7 +117,12 @@ public class JettyResponseSender { private void beginSend() { String length = response.getHeaders().get(HttpHeader.CONTENT_LENGTH.asString()); - if (length == null || "0".equals(length) || response.getCode() == 204 || "HEAD".equals(servletRequest.getMethod())) { + if (length == null + || "0".equals(length) + || response.getCode() == 204 + || response.getCode() == 304 + || "HEAD".equals(servletRequest.getMethod()) + ) { servletResponse.setHeader(HttpHeader.CONTENT_LENGTH.asString(), null); } // Return a content length of -1 for HTTP code 204 (No content) diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/ErrorsPageResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/ErrorsPageResolver.java index c24b7fe8b..8b048ae76 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/ErrorsPageResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/ErrorsPageResolver.java @@ -40,7 +40,7 @@ public class ErrorsPageResolver implements Resolver { @Override public Optional resolve(Request request) { - return Optional.of(responseFactory.errorsPageResponse()); + return Optional.of(responseFactory.errorsPageResponse(request)); } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/PlayerPageResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/PlayerPageResolver.java index 0d617babb..96a172633 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/PlayerPageResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/PlayerPageResolver.java @@ -82,10 +82,11 @@ public class PlayerPageResolver implements Resolver { return Optional.empty(); } return path.getPart(1) - .map(playerName -> getResponse(request.getPath(), playerName)); + .map(playerName -> getResponse(request, playerName)); } - private Response getResponse(@Untrusted URIPath path, @Untrusted String playerName) { + private Response getResponse(@Untrusted Request request, @Untrusted String playerName) { + @Untrusted URIPath path = request.getPath(); UUID playerUUID = uuidUtility.getUUIDOf(playerName); if (playerUUID == null) return responseFactory.uuidNotFound404(); @@ -98,6 +99,6 @@ public class PlayerPageResolver implements Resolver { // Redirect /player/{uuid/name}/ to /player/{uuid} return responseFactory.redirectResponse("../" + Html.encodeToURL(playerUUID.toString())); } - return responseFactory.playerPageResponse(playerUUID); + return responseFactory.playerPageResponse(request, playerUUID); } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/PlayersPageResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/PlayersPageResolver.java index 7112902d2..f951e55ca 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/PlayersPageResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/PlayersPageResolver.java @@ -51,6 +51,6 @@ public class PlayersPageResolver implements Resolver { public Optional resolve(Request request) { // Redirect /players/ to /players if (request.getPath().getPart(1).isPresent()) return Optional.of(responseFactory.redirectResponse("/players")); - return Optional.of(responseFactory.playersPageResponse()); + return Optional.of(responseFactory.playersPageResponse(request)); } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/QueryPageResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/QueryPageResolver.java index 8ce03cdf7..7fb52f452 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/QueryPageResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/QueryPageResolver.java @@ -42,6 +42,6 @@ public class QueryPageResolver implements Resolver { @Override public Optional resolve(Request request) { - return Optional.of(responseFactory.queryPageResponse()); + return Optional.of(responseFactory.queryPageResponse(request)); } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/ServerPageResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/ServerPageResolver.java index e81fb3921..1cb8cde37 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/ServerPageResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/ServerPageResolver.java @@ -80,17 +80,17 @@ public class ServerPageResolver implements Resolver { return Optional.of(responseFactory.redirectResponse(directTo)); } - private Optional getServerPage(ServerUUID serverUUID, Request request) { + private Optional getServerPage(ServerUUID serverUUID, @Untrusted Request request) { boolean toNetworkPage = serverInfo.getServer().isProxy() && serverInfo.getServerUUID().equals(serverUUID); if (toNetworkPage) { if (request.getPath().getPart(0).map("network"::equals).orElse(false)) { - return Optional.of(responseFactory.networkPageResponse()); + return Optional.of(responseFactory.networkPageResponse(request)); } else { // Accessing /server/Server which should be redirected to /network return redirectToCurrentServer(); } } - return Optional.of(responseFactory.serverPageResponse(serverUUID)); + return Optional.of(responseFactory.serverPageResponse(request, serverUUID)); } private Optional getServerUUID(@Untrusted URIPath path) { diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/StaticResourceResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/StaticResourceResolver.java index ec45839d2..c898263a3 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/StaticResourceResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/StaticResourceResolver.java @@ -21,6 +21,7 @@ import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.request.Request; import com.djrapitops.plan.delivery.web.resolver.request.URIPath; import com.djrapitops.plan.delivery.webserver.ResponseFactory; +import com.djrapitops.plan.identification.Identifiers; import com.djrapitops.plan.utilities.dev.Untrusted; import org.apache.commons.lang3.StringUtils; @@ -53,17 +54,22 @@ public class StaticResourceResolver implements NoAuthResolver { private Response getResponse(Request request) { @Untrusted String resource = getPath(request).asString().substring(1); + @Untrusted Optional etag = Identifiers.getEtag(request); if (resource.endsWith(".css")) { - return responseFactory.cssResponse(resource); + return etag.map(tag -> responseFactory.cssResponse(tag, resource)) + .orElseGet(() -> responseFactory.cssResponse(resource)); } if (resource.endsWith(".js")) { - return responseFactory.javaScriptResponse(resource); + return etag.map(tag -> responseFactory.javaScriptResponse(tag, resource)) + .orElseGet(() -> responseFactory.javaScriptResponse(resource)); } if (resource.endsWith(".png")) { - return responseFactory.imageResponse(resource); + return etag.map(tag -> responseFactory.imageResponse(tag, resource)) + .orElseGet(() -> responseFactory.imageResponse(resource)); } if (StringUtils.endsWithAny(resource, ".woff", ".woff2", ".eot", ".ttf")) { - return responseFactory.fontResponse(resource); + return etag.map(tag -> responseFactory.fontResponse(tag, resource)) + .orElseGet(() -> responseFactory.fontResponse(resource)); } return null; } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/auth/LoginPageResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/auth/LoginPageResolver.java index cbb765eee..13c517904 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/auth/LoginPageResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/auth/LoginPageResolver.java @@ -52,6 +52,6 @@ public class LoginPageResolver implements NoAuthResolver { .filter(redirectBackTo -> !redirectBackTo.startsWith("http")); return Optional.of(responseFactory.redirectResponse(from.orElse("/"))); } - return Optional.of(responseFactory.loginPageResponse()); + return Optional.of(responseFactory.loginPageResponse(request)); } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/auth/RegisterPageResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/auth/RegisterPageResolver.java index df8af9c6d..37c3fd32e 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/auth/RegisterPageResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/auth/RegisterPageResolver.java @@ -50,6 +50,6 @@ public class RegisterPageResolver implements NoAuthResolver { if (user.isPresent() || !webServer.get().isAuthRequired()) { return Optional.of(responseFactory.redirectResponse("/")); } - return Optional.of(responseFactory.registerPageResponse()); + return Optional.of(responseFactory.registerPageResponse(request)); } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/ExtensionJSONResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/ExtensionJSONResolver.java index a47db1d51..ef666b980 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/ExtensionJSONResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/ExtensionJSONResolver.java @@ -17,8 +17,8 @@ package com.djrapitops.plan.delivery.webserver.resolver.json; import com.djrapitops.plan.delivery.domain.datatransfer.extension.ExtensionDataDto; +import com.djrapitops.plan.delivery.formatting.Formatter; import com.djrapitops.plan.delivery.web.resolver.MimeType; -import com.djrapitops.plan.delivery.web.resolver.Resolver; import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.exception.BadRequestException; import com.djrapitops.plan.delivery.web.resolver.exception.NotFoundException; @@ -53,7 +53,7 @@ import java.util.stream.Collectors; * @author AuroraLS3 */ @Singleton -public class ExtensionJSONResolver implements Resolver { +public class ExtensionJSONResolver extends JSONResolver { private final DBSystem dbSystem; private final Identifiers identifiers; @@ -66,6 +66,9 @@ public class ExtensionJSONResolver implements Resolver { this.jsonResolverService = jsonResolverService; } + @Override + public Formatter getHttpLastModifiedFormatter() {return jsonResolverService.getHttpLastModifiedFormatter();} + @Override public boolean canAccess(Request request) { WebUser permissions = request.getUser().orElse(new WebUser("")); @@ -107,10 +110,7 @@ public class ExtensionJSONResolver implements Resolver { private Response getResponse(Request request, ServerUUID serverUUID) { JSONStorage.StoredJSON json = getJSON(request, serverUUID); - - return Response.builder() - .setJSONContent(json.json) - .build(); + return getCachedOrNewResponse(request, json); } private Map> getExtensionData(ServerUUID serverUUID) { diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/GraphsJSONResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/GraphsJSONResolver.java index 1915f136d..029eaf739 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/GraphsJSONResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/GraphsJSONResolver.java @@ -16,9 +16,8 @@ */ package com.djrapitops.plan.delivery.webserver.resolver.json; +import com.djrapitops.plan.delivery.formatting.Formatter; import com.djrapitops.plan.delivery.rendering.json.graphs.GraphJSONCreator; -import com.djrapitops.plan.delivery.web.resolver.MimeType; -import com.djrapitops.plan.delivery.web.resolver.Resolver; import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.exception.BadRequestException; import com.djrapitops.plan.delivery.web.resolver.request.Request; @@ -51,7 +50,7 @@ import java.util.Optional; */ @Singleton @Path("/v1/graph") -public class GraphsJSONResolver implements Resolver { +public class GraphsJSONResolver extends JSONResolver { private final Identifiers identifiers; private final AsyncJSONResolverService jsonResolverService; @@ -60,13 +59,17 @@ public class GraphsJSONResolver implements Resolver { @Inject public GraphsJSONResolver( Identifiers identifiers, - AsyncJSONResolverService jsonResolverService, GraphJSONCreator graphJSON + AsyncJSONResolverService jsonResolverService, + GraphJSONCreator graphJSON ) { this.identifiers = identifiers; this.jsonResolverService = jsonResolverService; this.graphJSON = graphJSON; } + @Override + public Formatter getHttpLastModifiedFormatter() {return jsonResolverService.getHttpLastModifiedFormatter();} + @Override public boolean canAccess(Request request) { return request.getUser().orElse(new WebUser("")).hasPermission("page.server"); @@ -126,10 +129,8 @@ public class GraphsJSONResolver implements Resolver { DataID dataID = getDataID(type); - return Response.builder() - .setMimeType(MimeType.JSON) - .setJSONContent(getGraphJSON(request, dataID).json) - .build(); + JSONStorage.StoredJSON storedJSON = getGraphJSON(request, dataID); + return getCachedOrNewResponse(request, storedJSON); } private JSONStorage.StoredJSON getGraphJSON(@Untrusted Request request, DataID dataID) { diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/JSONResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/JSONResolver.java new file mode 100644 index 000000000..5cbac32c4 --- /dev/null +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/JSONResolver.java @@ -0,0 +1,67 @@ +/* + * 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 . + */ +package com.djrapitops.plan.delivery.webserver.resolver.json; + +import com.djrapitops.plan.delivery.formatting.Formatter; +import com.djrapitops.plan.delivery.web.resolver.MimeType; +import com.djrapitops.plan.delivery.web.resolver.Resolver; +import com.djrapitops.plan.delivery.web.resolver.Response; +import com.djrapitops.plan.delivery.web.resolver.request.Request; +import com.djrapitops.plan.delivery.webserver.CacheStrategy; +import com.djrapitops.plan.delivery.webserver.cache.JSONStorage; +import com.djrapitops.plan.identification.Identifiers; +import com.djrapitops.plan.utilities.dev.Untrusted; +import com.djrapitops.plan.utilities.java.Maps; +import org.eclipse.jetty.http.HttpHeader; + +import java.util.Optional; + +/** + * @author AuroraLS3 + */ +public abstract class JSONResolver implements Resolver { + + protected Response getCachedOrNewResponse(@Untrusted Request request, JSONStorage.StoredJSON storedJSON) { + if (storedJSON == null) { + return Response.builder() + .setMimeType(MimeType.JSON) + .setJSONContent(Maps.builder(String.class, String.class) + .put("error", "Json failed to generate for some reason, see /Plan/logs for errors") + .build()) + .build(); + } + + Optional browserCached = Identifiers.getEtag(request); + if (browserCached.isPresent() && browserCached.get() == storedJSON.getTimestamp()) { + return Response.builder() + .setStatus(304) + .setContent(new byte[0]) + .build(); + } + + return Response.builder() + .setMimeType(MimeType.JSON) + .setJSONContent(storedJSON.getJson()) + .setHeader(HttpHeader.CACHE_CONTROL.asString(), CacheStrategy.CHECK_ETAG_USER_SPECIFIC) + .setHeader(HttpHeader.LAST_MODIFIED.asString(), getHttpLastModifiedFormatter().apply(storedJSON.getTimestamp())) + .setHeader(HttpHeader.ETAG.asString(), storedJSON.getTimestamp()) + .build(); + } + + protected abstract Formatter getHttpLastModifiedFormatter(); + +} diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/NetworkTabJSONResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/NetworkTabJSONResolver.java index eac329888..a709542c4 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/NetworkTabJSONResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/NetworkTabJSONResolver.java @@ -16,9 +16,8 @@ */ package com.djrapitops.plan.delivery.webserver.resolver.json; +import com.djrapitops.plan.delivery.formatting.Formatter; import com.djrapitops.plan.delivery.rendering.json.network.NetworkTabJSONCreator; -import com.djrapitops.plan.delivery.web.resolver.MimeType; -import com.djrapitops.plan.delivery.web.resolver.Resolver; import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.request.Request; import com.djrapitops.plan.delivery.web.resolver.request.WebUser; @@ -26,7 +25,6 @@ import com.djrapitops.plan.delivery.webserver.cache.AsyncJSONResolverService; import com.djrapitops.plan.delivery.webserver.cache.DataID; import com.djrapitops.plan.delivery.webserver.cache.JSONStorage; import com.djrapitops.plan.identification.Identifiers; -import com.djrapitops.plan.utilities.java.Maps; import java.util.Optional; import java.util.function.Supplier; @@ -36,7 +34,7 @@ import java.util.function.Supplier; * * @author AuroraLS3 */ -public class NetworkTabJSONResolver implements Resolver { +public class NetworkTabJSONResolver extends JSONResolver { private final DataID dataID; private final Supplier jsonCreator; @@ -51,6 +49,9 @@ public class NetworkTabJSONResolver implements Resolver { this.asyncJSONResolverService = asyncJSONResolverService; } + @Override + public Formatter getHttpLastModifiedFormatter() {return asyncJSONResolverService.getHttpLastModifiedFormatter();} + @Override public boolean canAccess(Request request) { return request.getUser().orElse(new WebUser("")).hasPermission("page.network"); @@ -63,18 +64,6 @@ public class NetworkTabJSONResolver implements Resolver { private Response getResponse(Request request) { JSONStorage.StoredJSON json = asyncJSONResolverService.resolve(Identifiers.getTimestamp(request), dataID, jsonCreator); - if (json == null) { - return Response.builder() - .setMimeType(MimeType.JSON) - .setJSONContent(Maps.builder(String.class, String.class) - .put("error", "Json failed to generate for some reason, see /Plan/logs for errors") - .build()) - .build(); - } - - return Response.builder() - .setMimeType(MimeType.JSON) - .setJSONContent(json.json) - .build(); + return getCachedOrNewResponse(request, json); } } \ No newline at end of file diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayerJSONResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayerJSONResolver.java index 76a0fab9b..c1412a96f 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayerJSONResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayerJSONResolver.java @@ -16,6 +16,8 @@ */ package com.djrapitops.plan.delivery.webserver.resolver.json; +import com.djrapitops.plan.delivery.formatting.Formatter; +import com.djrapitops.plan.delivery.formatting.Formatters; import com.djrapitops.plan.delivery.rendering.json.PlayerJSONCreator; import com.djrapitops.plan.delivery.web.resolver.MimeType; import com.djrapitops.plan.delivery.web.resolver.Resolver; @@ -23,6 +25,7 @@ import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.exception.BadRequestException; import com.djrapitops.plan.delivery.web.resolver.request.Request; import com.djrapitops.plan.delivery.web.resolver.request.WebUser; +import com.djrapitops.plan.delivery.webserver.CacheStrategy; import com.djrapitops.plan.identification.Identifiers; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -33,9 +36,11 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; +import org.eclipse.jetty.http.HttpHeader; import javax.inject.Inject; import javax.inject.Singleton; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -45,11 +50,14 @@ public class PlayerJSONResolver implements Resolver { private final Identifiers identifiers; private final PlayerJSONCreator jsonCreator; + private final Formatter httpLastModifiedFormatter; @Inject - public PlayerJSONResolver(Identifiers identifiers, PlayerJSONCreator jsonCreator) { + public PlayerJSONResolver(Identifiers identifiers, Formatters formatters, PlayerJSONCreator jsonCreator) { this.identifiers = identifiers; this.jsonCreator = jsonCreator; + + httpLastModifiedFormatter = formatters.httpLastModifiedLong(); } @Override @@ -88,9 +96,30 @@ public class PlayerJSONResolver implements Resolver { private Response getResponse(Request request) { UUID playerUUID = identifiers.getPlayerUUID(request); // Can throw BadRequestException + + Optional etag = Identifiers.getEtag(request); + if (etag.isPresent()) { + long lastSeen = jsonCreator.getLastSeen(playerUUID); + if (etag.get() == lastSeen) { + return Response.builder() + .setStatus(304) + .setContent(new byte[0]) + .build(); + } + } + + Map jsonAsMap = jsonCreator.createJSONAsMap(playerUUID); + long lastSeenRawValue = Optional.ofNullable(jsonAsMap.get("info")) + .map(Map.class::cast) + .map(info -> info.get("last_seen_raw_value")) + .map(Long.class::cast) + .orElseGet(System::currentTimeMillis); return Response.builder() .setMimeType(MimeType.JSON) - .setJSONContent(jsonCreator.createJSONAsMap(playerUUID)) + .setJSONContent(jsonAsMap) + .setHeader(HttpHeader.CACHE_CONTROL.asString(), CacheStrategy.CHECK_ETAG_USER_SPECIFIC) + .setHeader(HttpHeader.LAST_MODIFIED.asString(), httpLastModifiedFormatter.apply(lastSeenRawValue)) + .setHeader(HttpHeader.ETAG.asString(), lastSeenRawValue) .build(); } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayerKillsJSONResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayerKillsJSONResolver.java index 922448adc..3ba66a62f 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayerKillsJSONResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayerKillsJSONResolver.java @@ -16,9 +16,9 @@ */ package com.djrapitops.plan.delivery.webserver.resolver.json; +import com.djrapitops.plan.delivery.formatting.Formatter; import com.djrapitops.plan.delivery.rendering.json.JSONFactory; import com.djrapitops.plan.delivery.web.resolver.MimeType; -import com.djrapitops.plan.delivery.web.resolver.Resolver; import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.request.Request; import com.djrapitops.plan.delivery.web.resolver.request.WebUser; @@ -49,7 +49,7 @@ import java.util.Optional; */ @Singleton @Path("/v1/kills") -public class PlayerKillsJSONResolver implements Resolver { +public class PlayerKillsJSONResolver extends JSONResolver { private final Identifiers identifiers; private final AsyncJSONResolverService jsonResolverService; @@ -66,6 +66,9 @@ public class PlayerKillsJSONResolver implements Resolver { this.jsonFactory = jsonFactory; } + @Override + public Formatter getHttpLastModifiedFormatter() {return jsonResolverService.getHttpLastModifiedFormatter();} + @Override public boolean canAccess(Request request) { return request.getUser().orElse(new WebUser("")).hasPermission("page.server"); @@ -99,9 +102,6 @@ public class PlayerKillsJSONResolver implements Resolver { JSONStorage.StoredJSON storedJSON = jsonResolverService.resolve(timestamp, DataID.KILLS, serverUUID, theUUID -> Collections.singletonMap("player_kills", jsonFactory.serverPlayerKillsAsJSONMaps(theUUID)) ); - return Response.builder() - .setMimeType(MimeType.JSON) - .setJSONContent(storedJSON.json) - .build(); + return getCachedOrNewResponse(request, storedJSON); } } \ No newline at end of file diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayersTableJSONResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayersTableJSONResolver.java index 307b46ca7..33dea3755 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayersTableJSONResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/PlayersTableJSONResolver.java @@ -16,9 +16,9 @@ */ package com.djrapitops.plan.delivery.webserver.resolver.json; +import com.djrapitops.plan.delivery.formatting.Formatter; import com.djrapitops.plan.delivery.rendering.json.JSONFactory; import com.djrapitops.plan.delivery.web.resolver.MimeType; -import com.djrapitops.plan.delivery.web.resolver.Resolver; import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.request.Request; import com.djrapitops.plan.delivery.web.resolver.request.WebUser; @@ -49,7 +49,7 @@ import java.util.Optional; */ @Singleton @Path("/v1/players") -public class PlayersTableJSONResolver implements Resolver { +public class PlayersTableJSONResolver extends JSONResolver { private final Identifiers identifiers; private final AsyncJSONResolverService jsonResolverService; @@ -66,6 +66,9 @@ public class PlayersTableJSONResolver implements Resolver { this.jsonFactory = jsonFactory; } + @Override + public Formatter getHttpLastModifiedFormatter() {return jsonResolverService.getHttpLastModifiedFormatter();} + @Override public boolean canAccess(Request request) { WebUser user = request.getUser().orElse(new WebUser("")); @@ -95,10 +98,8 @@ public class PlayersTableJSONResolver implements Resolver { } private Response getResponse(Request request) { - return Response.builder() - .setMimeType(MimeType.JSON) - .setJSONContent(getStoredJSON(request).json) - .build(); + JSONStorage.StoredJSON storedJSON = getStoredJSON(request); + return getCachedOrNewResponse(request, storedJSON); } private JSONStorage.StoredJSON getStoredJSON(@Untrusted Request request) { diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/ServerTabJSONResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/ServerTabJSONResolver.java index 88ee29b98..e0fb693b7 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/ServerTabJSONResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/ServerTabJSONResolver.java @@ -16,14 +16,14 @@ */ package com.djrapitops.plan.delivery.webserver.resolver.json; +import com.djrapitops.plan.delivery.formatting.Formatter; import com.djrapitops.plan.delivery.rendering.json.ServerTabJSONCreator; -import com.djrapitops.plan.delivery.web.resolver.MimeType; -import com.djrapitops.plan.delivery.web.resolver.Resolver; import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.request.Request; import com.djrapitops.plan.delivery.web.resolver.request.WebUser; import com.djrapitops.plan.delivery.webserver.cache.AsyncJSONResolverService; import com.djrapitops.plan.delivery.webserver.cache.DataID; +import com.djrapitops.plan.delivery.webserver.cache.JSONStorage; import com.djrapitops.plan.identification.Identifiers; import com.djrapitops.plan.identification.ServerUUID; import com.djrapitops.plan.utilities.dev.Untrusted; @@ -36,7 +36,7 @@ import java.util.function.Function; * * @author AuroraLS3 */ -public class ServerTabJSONResolver implements Resolver { +public class ServerTabJSONResolver extends JSONResolver { private final DataID dataID; private final Identifiers identifiers; @@ -53,6 +53,9 @@ public class ServerTabJSONResolver implements Resolver { this.asyncJSONResolverService = asyncJSONResolverService; } + @Override + public Formatter getHttpLastModifiedFormatter() {return asyncJSONResolverService.getHttpLastModifiedFormatter();} + @Override public boolean canAccess(Request request) { return request.getUser().orElse(new WebUser("")).hasPermission("page.server"); @@ -65,9 +68,7 @@ public class ServerTabJSONResolver implements Resolver { private Response getResponse(@Untrusted Request request) { ServerUUID serverUUID = identifiers.getServerUUID(request); // Can throw BadRequestException - return Response.builder() - .setMimeType(MimeType.JSON) - .setJSONContent(asyncJSONResolverService.resolve(Identifiers.getTimestamp(request), dataID, serverUUID, jsonCreator).json) - .build(); + JSONStorage.StoredJSON storedJson = asyncJSONResolverService.resolve(Identifiers.getTimestamp(request), dataID, serverUUID, jsonCreator); + return getCachedOrNewResponse(request, storedJson); } } \ No newline at end of file diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/SessionsJSONResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/SessionsJSONResolver.java index 716ffcba7..d8e906793 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/SessionsJSONResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/json/SessionsJSONResolver.java @@ -16,9 +16,9 @@ */ package com.djrapitops.plan.delivery.webserver.resolver.json; +import com.djrapitops.plan.delivery.formatting.Formatter; import com.djrapitops.plan.delivery.rendering.json.JSONFactory; import com.djrapitops.plan.delivery.web.resolver.MimeType; -import com.djrapitops.plan.delivery.web.resolver.Resolver; import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.request.Request; import com.djrapitops.plan.delivery.web.resolver.request.WebUser; @@ -50,7 +50,7 @@ import java.util.Optional; */ @Singleton @Path("/v1/sessions") -public class SessionsJSONResolver implements Resolver { +public class SessionsJSONResolver extends JSONResolver { private final Identifiers identifiers; private final AsyncJSONResolverService jsonResolverService; @@ -67,6 +67,9 @@ public class SessionsJSONResolver implements Resolver { this.jsonFactory = jsonFactory; } + @Override + public Formatter getHttpLastModifiedFormatter() {return jsonResolverService.getHttpLastModifiedFormatter();} + @Override public boolean canAccess(Request request) { return request.getUser().orElse(new WebUser("")).hasPermission("page.server"); @@ -92,10 +95,8 @@ public class SessionsJSONResolver implements Resolver { } private Response getResponse(Request request) { - return Response.builder() - .setMimeType(MimeType.JSON) - .setJSONContent(getStoredJSON(request).json) - .build(); + JSONStorage.StoredJSON result = getStoredJSON(request); + return getCachedOrNewResponse(request, result); } private JSONStorage.StoredJSON getStoredJSON(@Untrusted Request request) { diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/swagger/SwaggerPageResolver.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/swagger/SwaggerPageResolver.java index 294c277dc..b2eacfe2d 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/swagger/SwaggerPageResolver.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/resolver/swagger/SwaggerPageResolver.java @@ -44,6 +44,6 @@ public class SwaggerPageResolver implements Resolver { @Override public Optional resolve(Request request) { - return Optional.of(responseFactory.reactPageResponse()); + return Optional.of(responseFactory.reactPageResponse(request)); } } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/identification/Identifiers.java b/Plan/common/src/main/java/com/djrapitops/plan/identification/Identifiers.java index 6feb84544..4efcfa8aa 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/identification/Identifiers.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/identification/Identifiers.java @@ -22,6 +22,7 @@ import com.djrapitops.plan.storage.database.DBSystem; import com.djrapitops.plan.storage.database.queries.objects.ServerQueries; import com.djrapitops.plan.storage.database.queries.objects.UserIdentifierQueries; import com.djrapitops.plan.utilities.dev.Untrusted; +import org.eclipse.jetty.http.HttpHeader; import org.jetbrains.annotations.Nullable; import javax.inject.Inject; @@ -67,9 +68,11 @@ public class Identifiers { public static Optional getTimestamp(@Untrusted Request request) { try { long currentTime = System.currentTimeMillis(); - long timestamp = request.getQuery().get("timestamp") + long timestamp = request.getHeader("X-Plan-Timestamp") .map(Long::parseLong) - .orElse(currentTime); + .orElseGet(() -> request.getQuery().get("timestamp") + .map(Long::parseLong) + .orElse(currentTime)); if (currentTime + TimeUnit.SECONDS.toMillis(10L) < timestamp) { return Optional.empty(); } @@ -79,6 +82,17 @@ public class Identifiers { } } + public static Optional getEtag(Request request) { + return request.getHeader(HttpHeader.IF_NONE_MATCH.asString()) + .map(tag -> { + try { + return Long.parseLong(tag); + } catch (NumberFormatException notANumber) { + throw new BadRequestException("'" + HttpHeader.IF_NONE_MATCH.asString() + "'-header was not a number. Clear browser cache."); + } + }); + } + /** * Obtain UUID of the server. * diff --git a/Plan/common/src/main/java/com/djrapitops/plan/storage/database/queries/containers/PlayerContainerQuery.java b/Plan/common/src/main/java/com/djrapitops/plan/storage/database/queries/containers/PlayerContainerQuery.java index 48e3b0fa6..13943c01e 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/storage/database/queries/containers/PlayerContainerQuery.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/storage/database/queries/containers/PlayerContainerQuery.java @@ -33,6 +33,7 @@ import com.djrapitops.plan.storage.database.queries.objects.*; import java.util.Collection; import java.util.List; +import java.util.Optional; import java.util.UUID; /** @@ -85,7 +86,11 @@ public class PlayerContainerQuery implements Query { return worldTimes; }); - container.putSupplier(PlayerKeys.LAST_SEEN, () -> SessionsMutator.forContainer(container).toLastSeen()); + container.putSupplier(PlayerKeys.LAST_SEEN, () -> { + Optional activeSession = container.getValue(PlayerKeys.ACTIVE_SESSION); + if (activeSession.isPresent()) return System.currentTimeMillis(); + return SessionsMutator.forContainer(container).toLastSeen(); + }); container.putSupplier(PlayerKeys.PLAYER_KILLS, () -> db.query(KillQueries.fetchPlayerKillsOfPlayer(uuid))); container.putSupplier(PlayerKeys.PLAYER_DEATHS_KILLS, () -> db.query(KillQueries.fetchPlayerDeathsOfPlayer(uuid))); container.putSupplier(PlayerKeys.PLAYER_KILL_COUNT, () -> container.getValue(PlayerKeys.PLAYER_KILLS).map(Collection::size).orElse(0)); diff --git a/Plan/common/src/main/java/com/djrapitops/plan/storage/database/queries/objects/SessionQueries.java b/Plan/common/src/main/java/com/djrapitops/plan/storage/database/queries/objects/SessionQueries.java index c61a492b7..d0f5d3298 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/storage/database/queries/objects/SessionQueries.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/storage/database/queries/objects/SessionQueries.java @@ -786,6 +786,14 @@ public class SessionQueries { }; } + public static Query lastSeen(UUID playerUUID) { + String sql = SELECT + "MAX(" + SessionsTable.SESSION_END + ") as last_seen" + + FROM + SessionsTable.TABLE_NAME + + WHERE + SessionsTable.USER_ID + "=" + UsersTable.SELECT_USER_ID; + return db -> db.queryOptional(sql, set -> set.getLong("last_seen"), playerUUID) + .orElse(0L); + } + public static Query lastSeen(UUID playerUUID, ServerUUID serverUUID) { String sql = SELECT + "MAX(" + SessionsTable.SESSION_END + ") as last_seen" + FROM + SessionsTable.TABLE_NAME + diff --git a/Plan/common/src/main/java/com/djrapitops/plan/storage/file/Resource.java b/Plan/common/src/main/java/com/djrapitops/plan/storage/file/Resource.java index 5fcdedfbe..2e2436611 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/storage/file/Resource.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/storage/file/Resource.java @@ -81,11 +81,13 @@ public interface Resource { * @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); - } + return WebResource.create(() -> { + try { + return asInputStream(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read '" + getResourceName() + "'", e); + } + }, getLastModifiedDate()); } /** diff --git a/Plan/react/dashboard/src/service/backendConfiguration.js b/Plan/react/dashboard/src/service/backendConfiguration.js index 2dbb3e843..61eaa3884 100644 --- a/Plan/react/dashboard/src/service/backendConfiguration.js +++ b/Plan/react/dashboard/src/service/backendConfiguration.js @@ -14,8 +14,9 @@ const isCurrentAddress = (address) => { export const baseAddress = javaReplaced.address.startsWith('PLAN_') || !isCurrentAddress(javaReplaced.address) ? "" : javaReplaced.address; export const staticSite = javaReplaced.isStatic === 'true'; -export const doSomeGetRequest = async (url, statusOptions) => { - return doSomeRequest(url, statusOptions, async () => axios.get(baseAddress + url)); +export const doSomeGetRequest = async (url, updateRequested, statusOptions) => { + return doSomeRequest(url, statusOptions, async () => axios.get(baseAddress + url, + updateRequested ? {headers: {"X-Plan-Timestamp": updateRequested}} : {})); } export const doSomePostRequest = async (url, statusOptions, body) => { @@ -72,6 +73,6 @@ export const doSomeRequest = async (url, statusOptions, axiosFunction) => { export const standard200option = {status: 200, get: response => response.data} const exported404options = {status: 404, get: () => 'Data not yet exported'} -export const doGetRequest = async url => { - return doSomeGetRequest(url, staticSite ? [standard200option, exported404options] : [standard200option]) +export const doGetRequest = async (url, updateRequested) => { + return doSomeGetRequest(url, updateRequested, staticSite ? [standard200option, exported404options] : [standard200option]) } \ No newline at end of file diff --git a/Plan/react/dashboard/src/service/networkService.js b/Plan/react/dashboard/src/service/networkService.js index 94ceb4907..4dae3f7d9 100644 --- a/Plan/react/dashboard/src/service/networkService.js +++ b/Plan/react/dashboard/src/service/networkService.js @@ -1,42 +1,42 @@ import {doGetRequest, staticSite} from "./backendConfiguration"; export const fetchNetworkOverview = async (updateRequested) => { - let url = `/v1/network/overview?timestamp=${updateRequested}`; + let url = `/v1/network/overview`; if (staticSite) url = `/data/network-overview.json`; - return doGetRequest(url); + return doGetRequest(url, updateRequested); } export const fetchServersOverview = async (updateRequested) => { - let url = `/v1/network/servers?timestamp=${updateRequested}`; + let url = `/v1/network/servers`; if (staticSite) url = `/data/network-servers.json`; - return doGetRequest(url); + return doGetRequest(url, updateRequested); } export const fetchServerPie = async (timestamp) => { - let url = `/v1/graph?type=serverPie×tamp=${timestamp}`; + let url = `/v1/graph?type=serverPie`; if (staticSite) url = `/data/graph-serverPie.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchNetworkSessionsOverview = async (timestamp) => { - let url = `/v1/network/sessionsOverview?timestamp=${timestamp}`; + let url = `/v1/network/sessionsOverview`; if (staticSite) url = `/data/network-sessionsOverview.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchNetworkPlayerbaseOverview = async (timestamp) => { - let url = `/v1/network/playerbaseOverview?timestamp=${timestamp}`; + let url = `/v1/network/playerbaseOverview`; if (staticSite) url = `/data/network-playerbaseOverview.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchNetworkPingTable = async (timestamp) => { - let url = `/v1/network/pingTable?timestamp=${timestamp}`; + let url = `/v1/network/pingTable`; if (staticSite) url = `/data/network-pingTable.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchNetworkPerformanceOverview = async (timestamp, serverUUIDs) => { - let url = `/v1/network/performanceOverview?servers=${encodeURIComponent(JSON.stringify(serverUUIDs))}×tamp=${timestamp}`; - return doGetRequest(url); + let url = `/v1/network/performanceOverview?servers=${encodeURIComponent(JSON.stringify(serverUUIDs))}`; + return doGetRequest(url, timestamp); } \ No newline at end of file diff --git a/Plan/react/dashboard/src/service/playerService.js b/Plan/react/dashboard/src/service/playerService.js index 1d8bb6c15..ee087d409 100644 --- a/Plan/react/dashboard/src/service/playerService.js +++ b/Plan/react/dashboard/src/service/playerService.js @@ -2,9 +2,9 @@ import {faMapSigns} from "@fortawesome/free-solid-svg-icons"; import {doSomeGetRequest, standard200option, staticSite} from "./backendConfiguration"; export const fetchPlayer = async (timestamp, uuid) => { - let url = `/v1/player?player=${uuid}×tamp=${timestamp}`; + let url = `/v1/player?player=${uuid}`; if (staticSite) url = `/player/${uuid}/player-${uuid}.json` - return doSomeGetRequest(url, [ + return doSomeGetRequest(url, timestamp, [ standard200option, { status: staticSite ? 404 : 400, diff --git a/Plan/react/dashboard/src/service/serverService.js b/Plan/react/dashboard/src/service/serverService.js index 95a841431..0c473b2b9 100644 --- a/Plan/react/dashboard/src/service/serverService.js +++ b/Plan/react/dashboard/src/service/serverService.js @@ -3,49 +3,49 @@ import {doGetRequest, staticSite} from "./backendConfiguration"; export const fetchServerIdentity = async (timestamp, identifier) => { let url = `/v1/serverIdentity?server=${identifier}`; if (staticSite) url = `/data/serverIdentity-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchServerOverview = async (timestamp, identifier) => { - let url = `/v1/serverOverview?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/serverOverview?server=${identifier}`; if (staticSite) url = `/data/serverOverview-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchOnlineActivityOverview = async (timestamp, identifier) => { - let url = `/v1/onlineOverview?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/onlineOverview?server=${identifier}`; if (staticSite) url = `/data/onlineOverview-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchPlayerbaseOverview = async (timestamp, identifier) => { - let url = `/v1/playerbaseOverview?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/playerbaseOverview?server=${identifier}`; if (staticSite) url = `/data/playerbaseOverview-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchSessionOverview = async (timestamp, identifier) => { - let url = `/v1/sessionsOverview?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/sessionsOverview?server=${identifier}`; if (staticSite) url = `/data/sessionsOverview-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchPvpPve = async (timestamp, identifier) => { - let url = `/v1/playerVersus?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/playerVersus?server=${identifier}`; if (staticSite) url = `/data/playerVersus-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchPerformanceOverview = async (timestamp, identifier) => { - let url = `/v1/performanceOverview?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/performanceOverview?server=${identifier}`; if (staticSite) url = `/data/performanceOverview-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchExtensionData = async (timestamp, identifier) => { - let url = `/v1/extensionData?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/extensionData?server=${identifier}`; if (staticSite) url = `/data/extensionData-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchSessions = async (timestamp, identifier) => { @@ -57,21 +57,21 @@ export const fetchSessions = async (timestamp, identifier) => { } const fetchSessionsServer = async (timestamp, identifier) => { - let url = `/v1/sessions?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/sessions?server=${identifier}`; if (staticSite) url = `/data/sessions-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } const fetchSessionsNetwork = async (timestamp) => { - let url = `/v1/sessions?timestamp=${timestamp}`; + let url = `/v1/sessions`; if (staticSite) url = `/data/sessions.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchKills = async (timestamp, identifier) => { - let url = `/v1/kills?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/kills?server=${identifier}`; if (staticSite) url = `/data/kills-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchPlayers = async (timestamp, identifier) => { @@ -82,21 +82,21 @@ export const fetchPlayers = async (timestamp, identifier) => { } } const fetchPlayersServer = async (timestamp, identifier) => { - let url = `/v1/players?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/players?server=${identifier}`; if (staticSite) url = `/data/players-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } const fetchPlayersNetwork = async (timestamp) => { - let url = `/v1/players?timestamp=${timestamp}`; + let url = `/v1/players`; if (staticSite) url = `/data/players.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchPingTable = async (timestamp, identifier) => { - let url = `/v1/pingTable?server=${identifier}×tamp=${timestamp}`; + let url = `/v1/pingTable?server=${identifier}`; if (staticSite) url = `/data/pingTable-${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchPlayersOnlineGraph = async (timestamp, identifier) => { @@ -108,15 +108,15 @@ export const fetchPlayersOnlineGraph = async (timestamp, identifier) => { } const fetchPlayersOnlineGraphServer = async (timestamp, identifier) => { - let url = `/v1/graph?type=playersOnline&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=playersOnline&server=${identifier}`; if (staticSite) url = `/data/graph-playersOnline_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } const fetchPlayersOnlineGraphNetwork = async (timestamp) => { - let url = `/v1/graph?type=playersOnline×tamp=${timestamp}`; + let url = `/v1/graph?type=playersOnline`; if (staticSite) url = `/data/graph-playersOnline.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchPlayerbaseDevelopmentGraph = async (timestamp, identifier) => { @@ -128,15 +128,15 @@ export const fetchPlayerbaseDevelopmentGraph = async (timestamp, identifier) => } const fetchPlayerbaseDevelopmentGraphServer = async (timestamp, identifier) => { - let url = `/v1/graph?type=activity&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=activity&server=${identifier}`; if (staticSite) url = `/data/graph-activity_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } const fetchPlayerbaseDevelopmentGraphNetwork = async (timestamp) => { - let url = `/v1/graph?type=activity×tamp=${timestamp}`; + let url = `/v1/graph?type=activity`; if (staticSite) url = `/data/graph-activity.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchDayByDayGraph = async (timestamp, identifier) => { @@ -148,15 +148,15 @@ export const fetchDayByDayGraph = async (timestamp, identifier) => { } const fetchDayByDayGraphServer = async (timestamp, identifier) => { - let url = `/v1/graph?type=uniqueAndNew&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=uniqueAndNew&server=${identifier}`; if (staticSite) url = `/data/graph-uniqueAndNew_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } const fetchDayByDayGraphNetwork = async (timestamp) => { - let url = `/v1/graph?type=uniqueAndNew×tamp=${timestamp}`; + let url = `/v1/graph?type=uniqueAndNew`; if (staticSite) url = `/data/graph-uniqueAndNew.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchHourByHourGraph = async (timestamp, identifier) => { @@ -168,33 +168,33 @@ export const fetchHourByHourGraph = async (timestamp, identifier) => { } const fetchHourByHourGraphServer = async (timestamp, identifier) => { - let url = `/v1/graph?type=hourlyUniqueAndNew&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=hourlyUniqueAndNew&server=${identifier}`; if (staticSite) url = `/data/graph-hourlyUniqueAndNew_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } const fetchHourByHourGraphNetwork = async (timestamp) => { - let url = `/v1/graph?type=hourlyUniqueAndNew×tamp=${timestamp}`; + let url = `/v1/graph?type=hourlyUniqueAndNew`; if (staticSite) url = `/data/graph-hourlyUniqueAndNew.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchServerCalendarGraph = async (timestamp, identifier) => { - let url = `/v1/graph?type=serverCalendar&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=serverCalendar&server=${identifier}`; if (staticSite) url = `/data/graph-serverCalendar_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchPunchCardGraph = async (timestamp, identifier) => { - let url = `/v1/graph?type=punchCard&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=punchCard&server=${identifier}`; if (staticSite) url = `/data/graph-punchCard_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchWorldPie = async (timestamp, identifier) => { - let url = `/v1/graph?type=worldPie&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=worldPie&server=${identifier}`; if (staticSite) url = `/data/graph-worldPie_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchGeolocations = async (timestamp, identifier) => { @@ -206,27 +206,27 @@ export const fetchGeolocations = async (timestamp, identifier) => { } const fetchGeolocationsServer = async (timestamp, identifier) => { - let url = `/v1/graph?type=geolocation&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=geolocation&server=${identifier}`; if (staticSite) url = `/data/graph-geolocation_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } const fetchGeolocationsNetwork = async (timestamp) => { - let url = `/v1/graph?type=geolocation×tamp=${timestamp}`; + let url = `/v1/graph?type=geolocation`; if (staticSite) url = `/data/graph-geolocation.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } -export const fetchOptimizedPerformance = async (timestamp, identifier, after) => { - let url = `/v1/graph?type=optimizedPerformance&server=${identifier}×tamp=${timestamp}&after=${after}`; +export const fetchOptimizedPerformance = async (timestamp, identifier) => { + let url = `/v1/graph?type=optimizedPerformance&server=${identifier}`; if (staticSite) url = `/data/graph-optimizedPerformance_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchPingGraph = async (timestamp, identifier) => { - let url = `/v1/graph?type=aggregatedPing&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=aggregatedPing&server=${identifier}`; if (staticSite) url = `/data/graph-aggregatedPing_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchJoinAddressPie = async (timestamp, identifier) => { @@ -238,15 +238,15 @@ export const fetchJoinAddressPie = async (timestamp, identifier) => { } const fetchJoinAddressPieServer = async (timestamp, identifier) => { - let url = `/v1/graph?type=joinAddressPie&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=joinAddressPie&server=${identifier}`; if (staticSite) url = `/data/graph-joinAddressPie_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } const fetchJoinAddressPieNetwork = async (timestamp) => { - let url = `/v1/graph?type=joinAddressPie×tamp=${timestamp}`; + let url = `/v1/graph?type=joinAddressPie`; if (staticSite) url = `/data/graph-joinAddressPie.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } export const fetchJoinAddressByDay = async (timestamp, identifier) => { @@ -258,13 +258,13 @@ export const fetchJoinAddressByDay = async (timestamp, identifier) => { } const fetchJoinAddressByDayServer = async (timestamp, identifier) => { - let url = `/v1/graph?type=joinAddressByDay&server=${identifier}×tamp=${timestamp}`; + let url = `/v1/graph?type=joinAddressByDay&server=${identifier}`; if (staticSite) url = `/data/graph-joinAddressByDay_${identifier}.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } const fetchJoinAddressByDayNetwork = async (timestamp) => { - let url = `/v1/graph?type=joinAddressByDay×tamp=${timestamp}`; + let url = `/v1/graph?type=joinAddressByDay`; if (staticSite) url = `/data/graph-joinAddressByDay.json`; - return doGetRequest(url); + return doGetRequest(url, timestamp); } diff --git a/Plan/react/dashboard/src/views/network/NetworkPerformance.js b/Plan/react/dashboard/src/views/network/NetworkPerformance.js index 8c1ec8ca9..e14a36723 100644 --- a/Plan/react/dashboard/src/views/network/NetworkPerformance.js +++ b/Plan/react/dashboard/src/views/network/NetworkPerformance.js @@ -52,13 +52,11 @@ const NetworkPerformance = () => { timestamp_f: '' } const time = new Date().getTime(); - const monthMs = 2592000000; - const after = time - monthMs; for (const index of visualizedServers) { const server = serverOptions[index]; - const {data, error} = await fetchOptimizedPerformance(time, encodeURIComponent(server.serverUUID), after); + const {data, error} = await fetchOptimizedPerformance(time, encodeURIComponent(server.serverUUID)); if (data) { loaded.servers.push(server); const values = data.values;