diff --git a/build.gradle.kts b/build.gradle.kts index 4ccd7b1..92b7b7a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,13 +20,6 @@ repositories { url = uri("https://nexus.telesphoreo.me/repository/plex/") } - maven { - url = uri("https://jitpack.io") - content { - includeGroup("com.github.MilkBowl") - } - } - maven { url = uri("https://maven.enginehub.org/repo/") } } @@ -40,9 +33,6 @@ dependencies { plexLibrary("org.eclipse.jetty:jetty-server:12.1.9") plexLibrary("org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.9") plexLibrary("org.eclipse.jetty:jetty-proxy:12.1.9") - implementation("com.github.MilkBowl:VaultAPI:1.7.1") { - exclude("org.bukkit", "bukkit") - } implementation(platform("com.intellectualsites.bom:bom-newest:1.56")) // Ref: https://github.com/IntellectualSites/bom compileOnly("com.fastasyncworldedit:FastAsyncWorldEdit-Core") implementation("commons-io:commons-io:2.22.0") diff --git a/settings.gradle.kts b/settings.gradle.kts index 1e1bcfa..a911397 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,5 +14,4 @@ pluginManagement { } } -rootProject.name = "Module-HTTPD" - +rootProject.name = "Module-HTTPD" \ No newline at end of file diff --git a/src/main/java/dev/plex/HTTPDModule.java b/src/main/java/dev/plex/HTTPDModule.java index d9755da..215bb57 100644 --- a/src/main/java/dev/plex/HTTPDModule.java +++ b/src/main/java/dev/plex/HTTPDModule.java @@ -3,22 +3,25 @@ package dev.plex; import dev.plex.authentication.AuthenticationManager; import dev.plex.cache.FileCache; import dev.plex.config.ModuleConfig; +import dev.plex.logging.Log; import dev.plex.module.PlexModule; +import dev.plex.ratelimit.RateLimitFilter; import dev.plex.request.AbstractServlet; import dev.plex.request.SchematicUploadServlet; import dev.plex.request.impl.*; import dev.plex.util.PlexLog; +import jakarta.servlet.DispatcherType; import jakarta.servlet.MultipartConfigElement; import lombok.Getter; -import net.milkbowl.vault.permission.Permission; import org.bukkit.Bukkit; -import org.bukkit.plugin.RegisteredServiceProvider; +import org.eclipse.jetty.ee10.servlet.FilterHolder; import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.ee10.servlet.ServletHandler; import org.eclipse.jetty.ee10.servlet.ServletHolder; import org.eclipse.jetty.server.*; import java.io.File; +import java.util.EnumSet; import java.util.concurrent.atomic.AtomicReference; public class HTTPDModule extends PlexModule @@ -27,16 +30,17 @@ public class HTTPDModule extends PlexModule private Thread serverThread; private AtomicReference atomicServer = new AtomicReference<>(); - @Getter - private static Permission permissions = null; - public static ModuleConfig moduleConfig; public static final FileCache fileCache = new FileCache(); public static final String template = AbstractServlet.readFileReal(HTTPDModule.class.getResourceAsStream("/httpd/template.html")); - private AuthenticationManager authenticationManager; + @Getter + private static AuthenticationManager authenticationManager; + + @Getter + private static File accessLogFile; @Override public void load() @@ -50,19 +54,13 @@ public class HTTPDModule extends PlexModule { moduleConfig.load(); PlexLog.debug("HTTPD Module Port: {0}", moduleConfig.getInt("server.port")); - if ((!Bukkit.getPluginManager().isPluginEnabled("Vault") || !setupPermissions())) - { - throw new RuntimeException("Plex-HTTPD requires the 'Vault' plugin as well as a Permissions plugin that hooks into 'Vault'. We recommend LuckPerms!"); - } - this.authenticationManager = new AuthenticationManager(); - if (this.authenticationManager.provider() != null) + accessLogFile = new File(getDataFolder(), moduleConfig.getString("server.logging.file-path", "httpd.log")); + + authenticationManager = new AuthenticationManager(); + if (authenticationManager.provider() == null) { - PlexLog.debug(this.authenticationManager.provider().generateLogin()); - } - else - { - PlexLog.debug("Provider was not found for Authentication so disabled"); + PlexLog.debug("Authentication is disabled or misconfigured"); } @@ -81,6 +79,8 @@ public class HTTPDModule extends PlexModule connector.setHost(moduleConfig.getString("server.bind-address")); connector.setPort(moduleConfig.getInt("server.port")); + context.addFilter(new FilterHolder(new RateLimitFilter()), "/*", EnumSet.of(DispatcherType.REQUEST)); + new IndefBansEndpoint(); new IndexEndpoint(); new ListEndpoint(); @@ -93,6 +93,7 @@ public class HTTPDModule extends PlexModule new AssetsEndpoint(); new PunishmentsUIEndpoint(); new IndefBansUIEndpoint(); + new AuthenticationEndpoint(); ServletHolder uploadHolder = HTTPDModule.context.addServlet(SchematicUploadServlet.class, "/api/schematics/uploading"); @@ -134,15 +135,7 @@ public class HTTPDModule extends PlexModule { e.printStackTrace(); } - } - - private boolean setupPermissions() - { - RegisteredServiceProvider rsp = Bukkit.getServicesManager().getRegistration(Permission.class); - if (rsp != null) { - permissions = rsp.getProvider(); - } - return permissions != null; + Log.shutdown(); } public static File getWorldeditFolder() diff --git a/src/main/java/dev/plex/authentication/AuthenticatedUser.java b/src/main/java/dev/plex/authentication/AuthenticatedUser.java index 4956592..433e67e 100644 --- a/src/main/java/dev/plex/authentication/AuthenticatedUser.java +++ b/src/main/java/dev/plex/authentication/AuthenticatedUser.java @@ -1,18 +1,19 @@ package dev.plex.authentication; -import com.google.common.collect.Lists; import lombok.Data; import lombok.experimental.Accessors; -import java.time.ZonedDateTime; -import java.util.LinkedList; +import java.time.Instant; @Data @Accessors(fluent = true) public class AuthenticatedUser { - private final String ip; - private final ZonedDateTime lastAuthenticated; - private final LinkedList roles = Lists.newLinkedList(); - private final UserType userType = UserType.UNKNOWN; + private final int userId; + private final String username; + private final boolean staff; + private final UserType userType; + private final String accessToken; + private final Instant accessTokenExpiresAt; + private final Instant authenticatedAt; } diff --git a/src/main/java/dev/plex/authentication/AuthenticationException.java b/src/main/java/dev/plex/authentication/AuthenticationException.java new file mode 100644 index 0000000..fc56f80 --- /dev/null +++ b/src/main/java/dev/plex/authentication/AuthenticationException.java @@ -0,0 +1,14 @@ +package dev.plex.authentication; + +public class AuthenticationException extends Exception +{ + public AuthenticationException(String message) + { + super(message); + } + + public AuthenticationException(String message, Throwable cause) + { + super(message, cause); + } +} diff --git a/src/main/java/dev/plex/authentication/AuthenticationManager.java b/src/main/java/dev/plex/authentication/AuthenticationManager.java index 68a0756..cff7507 100644 --- a/src/main/java/dev/plex/authentication/AuthenticationManager.java +++ b/src/main/java/dev/plex/authentication/AuthenticationManager.java @@ -1,9 +1,8 @@ package dev.plex.authentication; import dev.plex.HTTPDModule; -import dev.plex.authentication.impl.DiscordOAuth2Provider; +import dev.plex.authentication.impl.XenForoOAuth2Provider; import dev.plex.util.PlexLog; -import org.apache.commons.lang3.NotImplementedException; public class AuthenticationManager { @@ -18,32 +17,8 @@ public class AuthenticationManager return; } - PlexLog.debug("[HTTPD] Auth is enabled"); - - final String providerName = HTTPDModule.moduleConfig.getString("authentication.provider.name", ""); - if (providerName.isEmpty()) - { - PlexLog.error("OAuth2 Authentication is enabled but no provider was given!"); - provider = null; - return; - } - - PlexLog.debug("[HTTPD] Provider name is {0}", providerName); - - switch (providerName.toLowerCase()) - { - case "discord" -> { - provider = new DiscordOAuth2Provider(); - } - case "xenforo" -> { - throw new NotImplementedException("XenForo OAuth2 is not implemented yet!"); - } - default -> { - provider = null; - } - } - - PlexLog.log("Using {0} provider for authentication", providerName); + PlexLog.log("[HTTPD] XenForo OAuth2 authentication is enabled"); + provider = new XenForoOAuth2Provider(); } public OAuth2Provider provider() diff --git a/src/main/java/dev/plex/authentication/OAuth2Provider.java b/src/main/java/dev/plex/authentication/OAuth2Provider.java index 5267357..22b1e90 100644 --- a/src/main/java/dev/plex/authentication/OAuth2Provider.java +++ b/src/main/java/dev/plex/authentication/OAuth2Provider.java @@ -1,17 +1,19 @@ package dev.plex.authentication; -import org.eclipse.jetty.server.Response; - -import java.util.HashMap; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; public interface OAuth2Provider { - HashMap sessions(); + String SESSION_COOKIE = "plex_session"; - AuthenticatedUser login(Response response, UserType type); + String buildAuthorizeUrl(HttpServletRequest request); - String[] roles(AuthenticatedUser user); + AuthenticatedUser handleCallback(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException; - String generateLogin(); + AuthenticatedUser lookup(String sessionId); + AuthenticatedUser lookup(HttpServletRequest request); + + void logout(String sessionId); } diff --git a/src/main/java/dev/plex/authentication/UserType.java b/src/main/java/dev/plex/authentication/UserType.java index d41ad29..5066cad 100644 --- a/src/main/java/dev/plex/authentication/UserType.java +++ b/src/main/java/dev/plex/authentication/UserType.java @@ -2,5 +2,5 @@ package dev.plex.authentication; public enum UserType { - DISCORD, UNKNOWN + XENFORO, UNKNOWN } diff --git a/src/main/java/dev/plex/authentication/impl/DiscordOAuth2Provider.java b/src/main/java/dev/plex/authentication/impl/DiscordOAuth2Provider.java deleted file mode 100644 index 67cb09e..0000000 --- a/src/main/java/dev/plex/authentication/impl/DiscordOAuth2Provider.java +++ /dev/null @@ -1,77 +0,0 @@ -package dev.plex.authentication.impl; - -import com.google.common.collect.Maps; -import dev.plex.HTTPDModule; -import dev.plex.authentication.AuthenticatedUser; -import dev.plex.authentication.OAuth2Provider; -import dev.plex.authentication.UserType; -import dev.plex.util.PlexLog; -import org.eclipse.jetty.client.HttpClient; -import org.eclipse.jetty.server.Response; - -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; - -public class DiscordOAuth2Provider implements OAuth2Provider -{ - private final HashMap sessions = Maps.newHashMap(); - - private final String token; - private final String clientId; - private final String redirectUri; - - public DiscordOAuth2Provider() - { - token = System.getenv("BOT_TOKEN").isEmpty() ? HTTPDModule.moduleConfig.getString("authentication.provider.discord.token", System.getProperty("BOT_TOKEN", "")) : System.getenv("BOT_TOKEN"); - clientId = HTTPDModule.moduleConfig.getString("authentication.provider.discord.clientId", ""); - redirectUri = URLEncoder.encode(HTTPDModule.moduleConfig.getString("authentication.provider.redirectUri", ""), StandardCharsets.UTF_8); - - PlexLog.debug("[HTTPD] Client ID: {0}, Redirect URL: {1}", clientId, redirectUri); - - if (redirectUri.isEmpty()) - { - PlexLog.error("Provided authentication redirect url was empty for HTTPD!"); - return; - } - - if (token.isEmpty()) - { - PlexLog.error("Provided discord authentication token was empty for HTTPD!"); - return; - } - - if (clientId.isEmpty()) - { - PlexLog.error("Provided discord client ID was empty for HTTPD!"); - } - - } - - @Override - public HashMap sessions() - { - return sessions; - } - - @Override - public AuthenticatedUser login(Response response, UserType type) - { - return null; - } - - @Override - public String[] roles(AuthenticatedUser user) - { - return new String[0]; - } - - @Override - public String generateLogin() - { - return String.format("https://discord.com/oauth2/authorize?client_id=%s&scope=%s&redirect_uri=%s", - clientId, - "identify%20guilds%20guilds.members.read", - redirectUri); - } -} diff --git a/src/main/java/dev/plex/authentication/impl/XenForoOAuth2Provider.java b/src/main/java/dev/plex/authentication/impl/XenForoOAuth2Provider.java new file mode 100644 index 0000000..3f278a0 --- /dev/null +++ b/src/main/java/dev/plex/authentication/impl/XenForoOAuth2Provider.java @@ -0,0 +1,328 @@ +package dev.plex.authentication.impl; + +import dev.plex.HTTPDModule; +import dev.plex.authentication.AuthenticatedUser; +import dev.plex.authentication.AuthenticationException; +import dev.plex.authentication.OAuth2Provider; +import dev.plex.authentication.UserType; +import dev.plex.util.PlexLog; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.json.JSONObject; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class XenForoOAuth2Provider implements OAuth2Provider +{ + private static final String AUTHORIZE_PATH = "/oauth2/authorize"; + private static final String TOKEN_PATH = "/api/oauth2/token"; + private static final String REVOKE_PATH = "/api/oauth2/revoke"; + private static final String ME_PATH = "/api/me"; + private static final String SCOPE = "user:read"; + private static final Duration PENDING_TTL = Duration.ofMinutes(10); + + private final SecureRandom random = new SecureRandom(); + private final Base64.Encoder b64 = Base64.getUrlEncoder().withoutPadding(); + private final Map pending = new ConcurrentHashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + + private final String authorizeUrl; + private final String tokenUrl; + private final String revokeUrl; + private final String meUrl; + private final String referer; + private final String clientId; + private final String clientSecret; + private final String redirectUri; + private final Duration sessionTtl; + + public XenForoOAuth2Provider() + { + String domain = HTTPDModule.moduleConfig.getString("authentication.provider.xenforo.domain", ""); + this.clientId = HTTPDModule.moduleConfig.getString("authentication.provider.xenforo.clientId", ""); + this.clientSecret = HTTPDModule.moduleConfig.getString("authentication.provider.xenforo.clientSecret", ""); + this.redirectUri = HTTPDModule.moduleConfig.getString("authentication.provider.redirectUri", ""); + long ttlMinutes = HTTPDModule.moduleConfig.getLong("authentication.provider.xenforo.sessionMinutes", 1440L); + this.sessionTtl = Duration.ofMinutes(Math.max(ttlMinutes, 1L)); + + if (domain.isEmpty() || clientId.isEmpty() || clientSecret.isEmpty() || redirectUri.isEmpty()) + { + PlexLog.error("XenForo OAuth2 misconfigured: domain, clientId, clientSecret, redirectUri are all required."); + } + + String base = "https://" + domain.replaceFirst("^https?://", "").replaceAll("/+$", ""); + this.authorizeUrl = base + AUTHORIZE_PATH; + this.tokenUrl = base + TOKEN_PATH; + this.revokeUrl = base + REVOKE_PATH; + this.meUrl = base + ME_PATH; + this.referer = refererOrigin(redirectUri, base); + } + + private static String refererOrigin(String redirectUri, String fallback) + { + try + { + URI uri = URI.create(redirectUri); + if (uri.getScheme() != null && uri.getHost() != null) + { + String port = uri.getPort() == -1 ? "" : ":" + uri.getPort(); + return uri.getScheme() + "://" + uri.getHost() + port + "/"; + } + } + catch (IllegalArgumentException ignored) {} + return fallback + "/"; + } + + @Override + public String buildAuthorizeUrl(HttpServletRequest request) + { + String state = randomToken(32); + String verifier = randomToken(48); + String challenge = pkceChallenge(verifier); + pending.put(state, new PendingLogin(verifier, Instant.now().plus(PENDING_TTL))); + purgeExpiredPending(); + + return authorizeUrl + + "?response_type=code" + + "&client_id=" + enc(clientId) + + "&redirect_uri=" + enc(redirectUri) + + "&scope=" + enc(SCOPE) + + "&state=" + enc(state) + + "&code_challenge=" + enc(challenge) + + "&code_challenge_method=S256"; + } + + @Override + public AuthenticatedUser handleCallback(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException + { + String error = request.getParameter("error"); + if (error != null && !error.isEmpty()) + { + throw new AuthenticationException("XenForo returned error: " + error); + } + String code = request.getParameter("code"); + String state = request.getParameter("state"); + if (code == null || state == null) + { + throw new AuthenticationException("Missing code or state in callback."); + } + PendingLogin pendingLogin = pending.remove(state); + if (pendingLogin == null || pendingLogin.expiresAt.isBefore(Instant.now())) + { + throw new AuthenticationException("Invalid or expired state parameter."); + } + + TokenResponse token = exchangeCode(code, pendingLogin.verifier); + JSONObject me = fetchMe(token.accessToken); + if (!me.optBoolean("is_staff", false)) + { + revokeToken(token.accessToken); + throw new AuthenticationException("Account is not a staff member."); + } + + Instant tokenExpiresAt = Instant.now().plusSeconds(Math.max(token.expiresIn, 60)); + AuthenticatedUser user = new AuthenticatedUser( + me.optInt("user_id", 0), + me.optString("username", "unknown"), + true, + UserType.XENFORO, + token.accessToken, + tokenExpiresAt, + Instant.now()); + + String sessionId = randomToken(32); + sessions.put(sessionId, user); + + Cookie cookie = new Cookie(SESSION_COOKIE, sessionId); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge((int) sessionTtl.getSeconds()); + cookie.setAttribute("SameSite", "Lax"); + if (request.isSecure() || "https".equalsIgnoreCase(request.getHeader("X-Forwarded-Proto"))) + { + cookie.setSecure(true); + } + response.addCookie(cookie); + + return user; + } + + @Override + public AuthenticatedUser lookup(String sessionId) + { + if (sessionId == null) return null; + AuthenticatedUser user = sessions.get(sessionId); + if (user == null) return null; + if (user.authenticatedAt().plus(sessionTtl).isBefore(Instant.now())) + { + sessions.remove(sessionId); + return null; + } + return user; + } + + @Override + public AuthenticatedUser lookup(HttpServletRequest request) + { + Cookie[] cookies = request.getCookies(); + if (cookies == null) return null; + for (Cookie cookie : cookies) + { + if (SESSION_COOKIE.equals(cookie.getName())) + { + return lookup(cookie.getValue()); + } + } + return null; + } + + @Override + public void logout(String sessionId) + { + if (sessionId == null) return; + AuthenticatedUser user = sessions.remove(sessionId); + if (user != null && user.accessToken() != null) + { + revokeToken(user.accessToken()); + } + } + + private TokenResponse exchangeCode(String code, String verifier) throws AuthenticationException + { + String body = "grant_type=authorization_code" + + "&code=" + enc(code) + + "&redirect_uri=" + enc(redirectUri) + + "&client_id=" + enc(clientId) + + "&client_secret=" + enc(clientSecret) + + "&code_verifier=" + enc(verifier); + HttpRequest req = HttpRequest.newBuilder(URI.create(tokenUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .header("Referer", referer) + .header("User-Agent", "Plex-HTTPD") + .timeout(Duration.ofSeconds(15)) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse resp; + try + { + resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + } + catch (Exception e) + { + throw new AuthenticationException("Failed to call XenForo token endpoint", e); + } + if (resp.statusCode() / 100 != 2) + { + throw new AuthenticationException("Token endpoint returned " + resp.statusCode() + ": " + resp.body()); + } + JSONObject json = new JSONObject(resp.body()); + String accessToken = json.optString("access_token", ""); + if (accessToken.isEmpty()) + { + throw new AuthenticationException("Token response missing access_token: " + resp.body()); + } + return new TokenResponse(accessToken, json.optLong("expires_in", 3600L)); + } + + private JSONObject fetchMe(String accessToken) throws AuthenticationException + { + HttpRequest req = HttpRequest.newBuilder(URI.create(meUrl)) + .header("Authorization", "Bearer " + accessToken) + .header("Accept", "application/json") + .header("Referer", referer) + .header("User-Agent", "Plex-HTTPD") + .timeout(Duration.ofSeconds(15)) + .GET() + .build(); + HttpResponse resp; + try + { + resp = http.send(req, HttpResponse.BodyHandlers.ofString()); + } + catch (Exception e) + { + throw new AuthenticationException("Failed to call XenForo /api/me", e); + } + if (resp.statusCode() / 100 != 2) + { + throw new AuthenticationException("/api/me returned " + resp.statusCode() + ": " + resp.body()); + } + JSONObject json = new JSONObject(resp.body()); + JSONObject me = json.optJSONObject("me"); + return me == null ? json : me; + } + + private void revokeToken(String accessToken) + { + String body = "token=" + enc(accessToken) + + "&client_id=" + enc(clientId) + + "&client_secret=" + enc(clientSecret); + HttpRequest req = HttpRequest.newBuilder(URI.create(revokeUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Referer", referer) + .header("User-Agent", "Plex-HTTPD") + .timeout(Duration.ofSeconds(10)) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + try + { + http.send(req, HttpResponse.BodyHandlers.discarding()); + } + catch (Exception e) + { + PlexLog.debug("Failed to revoke XenForo token: {0}", e.getMessage()); + } + } + + private void purgeExpiredPending() + { + Instant now = Instant.now(); + pending.entrySet().removeIf(entry -> entry.getValue().expiresAt.isBefore(now)); + } + + private String randomToken(int bytes) + { + byte[] buf = new byte[bytes]; + random.nextBytes(buf); + return b64.encodeToString(buf); + } + + private String pkceChallenge(String verifier) + { + try + { + MessageDigest sha = MessageDigest.getInstance("SHA-256"); + byte[] digest = sha.digest(verifier.getBytes(StandardCharsets.US_ASCII)); + return b64.encodeToString(digest); + } + catch (NoSuchAlgorithmException e) + { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private static String enc(String value) + { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private record PendingLogin(String verifier, Instant expiresAt) {} + + private record TokenResponse(String accessToken, long expiresIn) {} +} diff --git a/src/main/java/dev/plex/logging/Log.java b/src/main/java/dev/plex/logging/Log.java index 2dcd972..5e0045a 100644 --- a/src/main/java/dev/plex/logging/Log.java +++ b/src/main/java/dev/plex/logging/Log.java @@ -4,23 +4,93 @@ import dev.plex.HTTPDModule; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; -import org.bukkit.ChatColor; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; public class Log { + private static final DateTimeFormatter STAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS z"); + + private static BufferedWriter writer; + private static File writerTarget; + public static void log(String message, Object... strings) + { + String formatted = format(message, strings); + writeFile(formatted); + if (HTTPDModule.moduleConfig != null && HTTPDModule.moduleConfig.getBoolean("server.logging.console", false)) + { + Bukkit.getConsoleSender().sendMessage(Component.text("[Plex HTTPD] ").color(NamedTextColor.DARK_AQUA).append(Component.text(formatted).color(NamedTextColor.GRAY))); + } + } + + public static synchronized void shutdown() + { + if (writer != null) + { + try + { + writer.flush(); + writer.close(); + } + catch (IOException ignored) {} + writer = null; + writerTarget = null; + } + } + + private static String format(String message, Object... strings) { for (int i = 0; i < strings.length; i++) { - if (message.contains("{" + i + "}")) + String token = "{" + i + "}"; + if (message.contains(token)) { - message = message.replace("{" + i + "}", strings[i].toString()); + message = message.replace(token, strings[i] == null ? "null" : strings[i].toString()); } } + return message; + } - if (HTTPDModule.moduleConfig.getBoolean("server.logging")) + private static synchronized void writeFile(String formatted) + { + if (HTTPDModule.moduleConfig == null) return; + if (!HTTPDModule.moduleConfig.getBoolean("server.logging.file", true)) return; + File target = HTTPDModule.getAccessLogFile(); + if (target == null) return; + if (writer == null || !target.equals(writerTarget)) { - Bukkit.getConsoleSender().sendMessage(Component.text("[Plex HTTPD] ").color(NamedTextColor.DARK_AQUA).append(Component.text(message).color(NamedTextColor.GRAY))); + try + { + if (writer != null) writer.close(); + target.getParentFile().mkdirs(); + writer = new BufferedWriter(new FileWriter(target, true)); + writerTarget = target; + } + catch (IOException e) + { + Bukkit.getLogger().warning("[Plex HTTPD] Failed to open access log " + target + ": " + e.getMessage()); + writer = null; + writerTarget = null; + return; + } + } + try + { + writer.write(STAMP.format(ZonedDateTime.now())); + writer.write(' '); + writer.write(formatted); + writer.newLine(); + writer.flush(); + } + catch (IOException e) + { + Bukkit.getLogger().warning("[Plex HTTPD] Failed to write access log: " + e.getMessage()); } } } diff --git a/src/main/java/dev/plex/ratelimit/RateLimitFilter.java b/src/main/java/dev/plex/ratelimit/RateLimitFilter.java new file mode 100644 index 0000000..bbd2ba3 --- /dev/null +++ b/src/main/java/dev/plex/ratelimit/RateLimitFilter.java @@ -0,0 +1,108 @@ +package dev.plex.ratelimit; + +import dev.plex.HTTPDModule; +import dev.plex.logging.Log; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +public class RateLimitFilter implements Filter +{ + private static final long EVICT_INTERVAL_MILLIS = 60_000L; + private static final long IP_IDLE_TIMEOUT_MILLIS = 5 * 60_000L; + private static final int IP_BUCKET_HARD_CAP = 50_000; + + private final boolean enabled; + private final TokenBucket globalBucket; + private final double ipCapacity; + private final double ipRefillPerSecond; + private final ConcurrentHashMap ipBuckets = new ConcurrentHashMap<>(); + private final AtomicLong nextEvictMillis = new AtomicLong(System.currentTimeMillis() + EVICT_INTERVAL_MILLIS); + + public RateLimitFilter() + { + this.enabled = HTTPDModule.moduleConfig.getBoolean("rate-limit.enabled", true); + double globalCapacity = HTTPDModule.moduleConfig.getDouble("rate-limit.global.capacity", 200.0); + double globalRate = HTTPDModule.moduleConfig.getDouble("rate-limit.global.per-second", 100.0); + this.globalBucket = new TokenBucket(globalCapacity, globalRate); + this.ipCapacity = HTTPDModule.moduleConfig.getDouble("rate-limit.per-ip.capacity", 30.0); + this.ipRefillPerSecond = HTTPDModule.moduleConfig.getDouble("rate-limit.per-ip.per-second", 10.0); + } + + @Override + public void init(FilterConfig filterConfig) {} + + @Override + public void destroy() {} + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException + { + if (!enabled) + { + chain.doFilter(request, response); + return; + } + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + if (!globalBucket.tryConsume()) + { + reject(httpRequest, httpResponse, globalBucket.retryAfterSeconds(), "global"); + return; + } + + String ip = clientIp(httpRequest); + TokenBucket bucket = bucketFor(ip); + if (!bucket.tryConsume()) + { + reject(httpRequest, httpResponse, bucket.retryAfterSeconds(), "per-ip"); + return; + } + + chain.doFilter(request, response); + } + + private TokenBucket bucketFor(String ip) + { + maybeEvict(); + return ipBuckets.computeIfAbsent(ip, k -> new TokenBucket(ipCapacity, ipRefillPerSecond)); + } + + private void maybeEvict() + { + long now = System.currentTimeMillis(); + long next = nextEvictMillis.get(); + if (now < next) return; + if (!nextEvictMillis.compareAndSet(next, now + EVICT_INTERVAL_MILLIS)) return; + ipBuckets.entrySet().removeIf(entry -> now - entry.getValue().lastActivityMillis() > IP_IDLE_TIMEOUT_MILLIS); + if (ipBuckets.size() > IP_BUCKET_HARD_CAP) + { + ipBuckets.clear(); + } + } + + private void reject(HttpServletRequest req, HttpServletResponse resp, long retryAfter, String scope) throws IOException + { + resp.setStatus(429); + resp.setHeader("Retry-After", String.valueOf(retryAfter)); + resp.setContentType("application/json; charset=UTF-8"); + resp.getWriter().write("{\"error\":\"Too Many Requests\",\"scope\":\"" + scope + "\",\"retry_after\":" + retryAfter + "}"); + Log.log("Rate limit hit ({0}) for {1} {2} from {3}, retry after {4}s", scope, req.getMethod(), req.getRequestURI(), clientIp(req), retryAfter); + } + + private static String clientIp(HttpServletRequest request) + { + String ip = request.getRemoteAddr(); + return ip == null ? "unknown" : ip; + } +} diff --git a/src/main/java/dev/plex/ratelimit/TokenBucket.java b/src/main/java/dev/plex/ratelimit/TokenBucket.java new file mode 100644 index 0000000..076af48 --- /dev/null +++ b/src/main/java/dev/plex/ratelimit/TokenBucket.java @@ -0,0 +1,52 @@ +package dev.plex.ratelimit; + +public class TokenBucket +{ + private final double capacity; + private final double refillPerSecond; + private double tokens; + private long lastRefillNanos; + private volatile long lastActivityMillis; + + public TokenBucket(double capacity, double refillPerSecond) + { + this.capacity = capacity; + this.refillPerSecond = refillPerSecond; + this.tokens = capacity; + this.lastRefillNanos = System.nanoTime(); + this.lastActivityMillis = System.currentTimeMillis(); + } + + public synchronized boolean tryConsume() + { + refill(); + lastActivityMillis = System.currentTimeMillis(); + if (tokens >= 1.0) + { + tokens -= 1.0; + return true; + } + return false; + } + + public synchronized long retryAfterSeconds() + { + refill(); + double deficit = 1.0 - tokens; + if (deficit <= 0) return 0; + return Math.max(1L, (long) Math.ceil(deficit / refillPerSecond)); + } + + public long lastActivityMillis() + { + return lastActivityMillis; + } + + private void refill() + { + long now = System.nanoTime(); + double elapsedSeconds = (now - lastRefillNanos) / 1_000_000_000.0; + tokens = Math.min(capacity, tokens + elapsedSeconds * refillPerSecond); + lastRefillNanos = now; + } +} diff --git a/src/main/java/dev/plex/request/AbstractServlet.java b/src/main/java/dev/plex/request/AbstractServlet.java index 900fb75..508446c 100644 --- a/src/main/java/dev/plex/request/AbstractServlet.java +++ b/src/main/java/dev/plex/request/AbstractServlet.java @@ -2,6 +2,9 @@ package dev.plex.request; import com.google.common.collect.Lists; import dev.plex.HTTPDModule; +import dev.plex.authentication.AuthenticatedUser; +import dev.plex.authentication.AuthenticationManager; +import dev.plex.authentication.OAuth2Provider; import dev.plex.logging.Log; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServlet; @@ -133,6 +136,26 @@ public class AbstractServlet extends HttpServlet return requestPath.isEmpty() ? "/" : requestPath; } + public static AuthenticatedUser currentUser(HttpServletRequest request) + { + AuthenticationManager manager = HTTPDModule.getAuthenticationManager(); + if (manager == null) return null; + OAuth2Provider provider = manager.provider(); + if (provider == null) return null; + return provider.lookup(request); + } + + public static AuthenticatedUser currentStaff(HttpServletRequest request) + { + AuthenticatedUser user = currentUser(request); + return (user != null && user.staff()) ? user : null; + } + + public static String signInPrompt(String action) + { + return "You must sign in as staff " + action + "."; + } + public static String readFile(InputStream filename) { String base = HTTPDModule.template; diff --git a/src/main/java/dev/plex/request/SchematicUploadServlet.java b/src/main/java/dev/plex/request/SchematicUploadServlet.java index a9be1da..34a1366 100644 --- a/src/main/java/dev/plex/request/SchematicUploadServlet.java +++ b/src/main/java/dev/plex/request/SchematicUploadServlet.java @@ -3,8 +3,8 @@ package dev.plex.request; import com.sk89q.worldedit.extent.clipboard.io.ClipboardFormat; import com.sk89q.worldedit.extent.clipboard.io.ClipboardFormats; import dev.plex.HTTPDModule; -import dev.plex.cache.DataUtils; -import dev.plex.player.PlexPlayer; +import dev.plex.authentication.AuthenticatedUser; +import dev.plex.logging.Log; import dev.plex.util.PlexLog; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServlet; @@ -12,8 +12,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.Part; import org.apache.commons.io.FileUtils; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; import java.io.File; import java.io.FileInputStream; @@ -29,22 +27,10 @@ public class SchematicUploadServlet extends HttpServlet @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - if (request.getRemoteAddr() == null) + AuthenticatedUser user = AbstractServlet.currentStaff(request); + if (user == null) { - response.getWriter().println(schematicUploadBadHTML("Your IP address could not be detected. Please ensure you are using IPv4.")); - return; - } - PlexPlayer plexPlayer = DataUtils.getPlayerByIP(request.getRemoteAddr()); - if (plexPlayer == null) - { - response.getWriter().println(schematicUploadBadHTML("Couldn't load your IP Address: " + request.getRemoteAddr() + ". Have you joined the server before?")); - return; - } - PlexLog.debug("Plex-HTTPD using permissions check"); - final OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(plexPlayer.getUuid()); - if (!HTTPDModule.getPermissions().playerHas(null, offlinePlayer, "plex.httpd.schematics.upload")) - { - response.getWriter().println(schematicUploadBadHTML("You do not have permission to upload schematics.")); + response.getWriter().println(schematicUploadBadHTML(AbstractServlet.signInPrompt("to upload schematics"))); return; } File worldeditFolder = HTTPDModule.getWorldeditFolder(); @@ -82,7 +68,8 @@ public class SchematicUploadServlet extends HttpServlet ClipboardFormat schematicFormat = ClipboardFormats.findByFile(schematicFile); if (schematicFormat == null) { - PlexLog.log("IP Address: " + request.getRemoteAddr() + " FAILED to upload schematic with filename: " + filename); + PlexLog.log(user.username() + " FAILED to upload schematic with filename: " + filename); + Log.log("{0} (xf:{1}) FAILED to upload schematic {2}", user.username(), user.userId(), filename); response.getWriter().println(schematicUploadBadHTML("Schematic is not a valid format.")); FileUtils.deleteQuietly(schematicFile); return; @@ -93,15 +80,16 @@ public class SchematicUploadServlet extends HttpServlet } catch (IOException e) { - PlexLog.log("IP Address: " + request.getRemoteAddr() + " FAILED to upload schematic with filename: " + filename); + PlexLog.log(user.username() + " FAILED to upload schematic with filename: " + filename); + Log.log("{0} (xf:{1}) FAILED to upload schematic {2}", user.username(), user.userId(), filename); response.getWriter().println(schematicUploadBadHTML("Schematic is not a valid format.")); FileUtils.deleteQuietly(schematicFile); return; } - // Files.copy(inputStream, schematic.toPath(), StandardCopyOption.REPLACE_EXISTING); inputStream.close(); response.getWriter().println(schematicUploadGoodHTML("Successfully uploaded " + filename + ".")); - PlexLog.log("IP Address: " + request.getRemoteAddr() + " uploaded schematic with filename: " + filename); + PlexLog.log(user.username() + " uploaded schematic with filename: " + filename); + Log.log("{0} (xf:{1}) uploaded schematic {2}", user.username(), user.userId(), filename); } private String schematicUploadBadHTML(String message) diff --git a/src/main/java/dev/plex/request/impl/AuthenticationEndpoint.java b/src/main/java/dev/plex/request/impl/AuthenticationEndpoint.java index 17f2e4c..11ff236 100644 --- a/src/main/java/dev/plex/request/impl/AuthenticationEndpoint.java +++ b/src/main/java/dev/plex/request/impl/AuthenticationEndpoint.java @@ -1,27 +1,124 @@ package dev.plex.request.impl; -import dev.plex.command.PlexCommand; -import dev.plex.command.annotation.CommandPermissions; +import dev.plex.HTTPDModule; +import dev.plex.authentication.AuthenticatedUser; +import dev.plex.authentication.AuthenticationException; +import dev.plex.authentication.OAuth2Provider; import dev.plex.request.AbstractServlet; import dev.plex.request.GetMapping; +import dev.plex.request.MappingHeaders; import dev.plex.util.PlexLog; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.bukkit.Bukkit; -import org.bukkit.command.Command; -import org.bukkit.command.CommandMap; -import org.bukkit.command.PluginIdentifiableCommand; +import org.json.JSONObject; -import java.util.*; +import java.io.IOException; public class AuthenticationEndpoint extends AbstractServlet { - - - @GetMapping(endpoint = "/oauth2") - public String login(HttpServletRequest request, HttpServletResponse response) + @GetMapping(endpoint = "/oauth2/login") + public String login(HttpServletRequest request, HttpServletResponse response) throws IOException { - // TODO: Nuh uh - return ""; + OAuth2Provider provider = HTTPDModule.getAuthenticationManager().provider(); + if (provider == null) + { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "Authentication is not enabled."); + return null; + } + response.sendRedirect(provider.buildAuthorizeUrl(request)); + return null; + } + + @GetMapping(endpoint = "/oauth2/callback") + public String callback(HttpServletRequest request, HttpServletResponse response) throws IOException + { + OAuth2Provider provider = HTTPDModule.getAuthenticationManager().provider(); + if (provider == null) + { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "Authentication is not enabled."); + return null; + } + try + { + provider.handleCallback(request, response); + } + catch (AuthenticationException e) + { + PlexLog.error("OAuth2 callback failed: " + e.getMessage()); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("text/html; charset=UTF-8"); + return "Sign-in failed" + + "" + + "

Sign-in failed

" + + "

" + escape(e.getMessage()) + "

" + + "

Try again

"; + } + response.sendRedirect("/"); + return null; + } + + @GetMapping(endpoint = "/oauth2/logout") + public String logout(HttpServletRequest request, HttpServletResponse response) throws IOException + { + OAuth2Provider provider = HTTPDModule.getAuthenticationManager().provider(); + if (provider == null) + { + response.sendRedirect("/"); + return null; + } + String sessionId = readSessionCookie(request); + provider.logout(sessionId); + + Cookie clear = new Cookie(OAuth2Provider.SESSION_COOKIE, ""); + clear.setHttpOnly(true); + clear.setPath("/"); + clear.setMaxAge(0); + response.addCookie(clear); + response.sendRedirect("/"); + return null; + } + + @GetMapping(endpoint = "/oauth2/me") + @MappingHeaders(headers = "content-type;application/json") + public String me(HttpServletRequest request, HttpServletResponse response) + { + OAuth2Provider provider = HTTPDModule.getAuthenticationManager().provider(); + if (provider == null) + { + return "{\"authenticated\":false,\"reason\":\"disabled\"}"; + } + AuthenticatedUser user = provider.lookup(request); + if (user == null) + { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return "{\"authenticated\":false}"; + } + return new JSONObject() + .put("authenticated", true) + .put("user_id", user.userId()) + .put("username", user.username()) + .put("is_staff", user.staff()) + .toString(); + } + + private static String readSessionCookie(HttpServletRequest request) + { + Cookie[] cookies = request.getCookies(); + if (cookies == null) return null; + for (Cookie cookie : cookies) + { + if (OAuth2Provider.SESSION_COOKIE.equals(cookie.getName())) + { + return cookie.getValue(); + } + } + return null; + } + + private static String escape(String s) + { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">"); } } diff --git a/src/main/java/dev/plex/request/impl/IndefBansEndpoint.java b/src/main/java/dev/plex/request/impl/IndefBansEndpoint.java index 6e94bd1..609bd8a 100644 --- a/src/main/java/dev/plex/request/impl/IndefBansEndpoint.java +++ b/src/main/java/dev/plex/request/impl/IndefBansEndpoint.java @@ -1,40 +1,22 @@ package dev.plex.request.impl; import com.google.gson.GsonBuilder; -import dev.plex.HTTPDModule; import dev.plex.Plex; -import dev.plex.cache.DataUtils; -import dev.plex.player.PlexPlayer; +import dev.plex.authentication.AuthenticatedUser; import dev.plex.request.AbstractServlet; import dev.plex.request.GetMapping; -import dev.plex.util.PlexLog; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; public class IndefBansEndpoint extends AbstractServlet { - private static final String TITLE = "Indefinite Bans - Plex HTTPD"; - @GetMapping(endpoint = "/api/indefbans/") public String getBans(HttpServletRequest request, HttpServletResponse response) { - String ipAddress = request.getRemoteAddr(); - if (ipAddress == null) + AuthenticatedUser user = currentStaff(request); + if (user == null) { - return indefbansHTML("An IP address could not be detected. Please ensure you are connecting using IPv4."); - } - final PlexPlayer player = DataUtils.getPlayerByIP(ipAddress); - if (player == null) - { - return indefbansHTML("Couldn't load your IP Address: " + ipAddress + ". Have you joined the server before?"); - } - PlexLog.debug("Plex-HTTPD using permissions check"); - final OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(player.getUuid()); - if (!HTTPDModule.getPermissions().playerHas(null, offlinePlayer, "plex.httpd.indefbans.access")) - { - return indefbansHTML("Not enough permissions to view this page."); + return indefbansHTML(signInPrompt("to view this page")); } response.setHeader("content-type", "application/json"); diff --git a/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java b/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java index d731cf2..e5a933c 100644 --- a/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java +++ b/src/main/java/dev/plex/request/impl/IndefBansUIEndpoint.java @@ -1,9 +1,7 @@ package dev.plex.request.impl; -import dev.plex.HTTPDModule; import dev.plex.Plex; -import dev.plex.cache.DataUtils; -import dev.plex.player.PlexPlayer; +import dev.plex.authentication.AuthenticatedUser; import dev.plex.punishment.PunishmentManager.IndefiniteBan; import dev.plex.request.AbstractServlet; import dev.plex.request.GetMapping; @@ -13,28 +11,15 @@ import jakarta.servlet.http.HttpServletResponse; import java.util.List; import java.util.UUID; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; - public class IndefBansUIEndpoint extends AbstractServlet { @GetMapping(endpoint = "/indefbans/") public String getBans(HttpServletRequest request, HttpServletResponse response) { - String ipAddress = request.getRemoteAddr(); - if (ipAddress == null) - { - return errorHTML("Cannot detect an IP address on this request."); - } - PlexPlayer viewer = DataUtils.getPlayerByIP(ipAddress); + AuthenticatedUser viewer = currentStaff(request); if (viewer == null) { - return errorHTML("This IP (" + escapeHtml(ipAddress) + ") is not linked to a known player."); - } - OfflinePlayer offline = Bukkit.getOfflinePlayer(viewer.getUuid()); - if (!HTTPDModule.getPermissions().playerHas(null, offline, "plex.httpd.indefbans.access")) - { - return errorHTML("Your account does not have plex.httpd.indefbans.access."); + return errorHTML(signInPrompt("to view this page")); } List bans = Plex.get().getPunishmentManager().getIndefiniteBans(); @@ -72,59 +57,50 @@ public class IndefBansUIEndpoint extends AbstractServlet private static String renderCard(IndefiniteBan ban) { - StringBuilder chips = new StringBuilder(); - for (String name : ban.getUsernames()) - { - chips.append(chip("user", escapeHtml(name))); - } - for (UUID id : ban.getUuids()) - { - chips.append(chip("uuid", id.toString())); - } - for (String ip : ban.getIps()) - { - chips.append(chip("ip", escapeHtml(ip))); - } String reason = (ban.getReason() == null || ban.getReason().isBlank()) ? "No reason provided" : escapeHtml(ban.getReason()); int total = ban.getUsernames().size() + ban.getUuids().size() + ban.getIps().size(); + + StringBuilder rows = new StringBuilder(); + if (!ban.getUsernames().isEmpty()) + { + rows.append(renderRow("users", "text-foreground/90 break-all", ban.getUsernames().stream().map(IndefBansUIEndpoint::escapeHtml).toList())); + } + if (!ban.getUuids().isEmpty()) + { + rows.append(renderRow("uuids", "text-foreground/55 break-all", ban.getUuids().stream().map(UUID::toString).toList())); + } + if (!ban.getIps().isEmpty()) + { + rows.append(renderRow("ips", "text-warning break-all", ban.getIps().stream().map(IndefBansUIEndpoint::escapeHtml).toList())); + } + return """

%s

%d %s
-
+
%s -
+
- """.formatted(reason, total, total == 1 ? "entry" : "entries", chips); + """.formatted(reason, total, total == 1 ? "entry" : "entries", rows); } - private static String chip(String kind, String value) + private static String renderRow(String label, String valueClasses, List values) { - String color = switch (kind) + StringBuilder items = new StringBuilder(); + for (String value : values) { - case "user" -> "bg-muted text-foreground"; - case "uuid" -> "bg-primary/10 text-primary"; - case "ip" -> "bg-warning/10 text-warning"; - default -> "bg-muted text-foreground"; - }; - String label = switch (kind) - { - case "user" -> "user"; - case "uuid" -> "uuid"; - case "ip" -> "ip"; - default -> ""; - }; + items.append("").append(value).append(""); + } return """ - - %s - %s - - """.formatted(color, label, value); +
%s
+
%s
+ """.formatted(label, valueClasses, items); } private String errorHTML(String message) diff --git a/src/main/java/dev/plex/request/impl/PunishmentsEndpoint.java b/src/main/java/dev/plex/request/impl/PunishmentsEndpoint.java index 83abda6..59a1d53 100644 --- a/src/main/java/dev/plex/request/impl/PunishmentsEndpoint.java +++ b/src/main/java/dev/plex/request/impl/PunishmentsEndpoint.java @@ -1,7 +1,7 @@ package dev.plex.request.impl; import com.google.gson.GsonBuilder; -import dev.plex.HTTPDModule; +import dev.plex.authentication.AuthenticatedUser; import dev.plex.cache.DataUtils; import dev.plex.player.PlexPlayer; import dev.plex.request.AbstractServlet; @@ -9,40 +9,31 @@ import dev.plex.request.GetMapping; import dev.plex.util.adapter.ZonedDateTimeAdapter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; + import java.time.ZonedDateTime; import java.util.UUID; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; public class PunishmentsEndpoint extends AbstractServlet { @GetMapping(endpoint = "/api/punishments/") public String getPunishments(HttpServletRequest request, HttpServletResponse response) { - String ipAddress = request.getRemoteAddr(); - if (ipAddress == null) - { - return punishmentsHTML("An IP address could not be detected. Please ensure you are connecting using IPv4."); - } if (request.getPathInfo() == null || request.getPathInfo().equals("/")) { return readFile(this.getClass().getResourceAsStream("/httpd/punishments.html")); } - UUID pathUUID; - String pathPlexPlayer; + PlexPlayer punishedPlayer; try { - pathUUID = UUID.fromString(request.getPathInfo().replace("/", "")); + UUID pathUUID = UUID.fromString(request.getPathInfo().replace("/", "")); punishedPlayer = DataUtils.getPlayer(pathUUID); } - catch (java.lang.IllegalArgumentException ignored) + catch (IllegalArgumentException ignored) { - pathPlexPlayer = request.getPathInfo().replace("/", ""); - punishedPlayer = DataUtils.getPlayer(pathPlexPlayer); + punishedPlayer = DataUtils.getPlayer(request.getPathInfo().replace("/", "")); } - final PlexPlayer player = DataUtils.getPlayerByIP(ipAddress); if (punishedPlayer == null) { return punishmentsHTML("This player has never joined the server before."); @@ -51,19 +42,13 @@ public class PunishmentsEndpoint extends AbstractServlet { return punishmentsGoodHTML("This player has been a good boy. They have no punishments!"); } - if (player == null) - { - // If the player is null, give it to them without the IPs - return new GsonBuilder().registerTypeAdapter(ZonedDateTime.class, new ZonedDateTimeAdapter()).setPrettyPrinting().create().toJson(punishedPlayer.getPunishments().stream().peek(punishment -> punishment.setIp("")).toList()); - } - final OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(player.getUuid()); - if (!HTTPDModule.getPermissions().playerHas(null, offlinePlayer, "plex.httpd.punishments.access")) - { - // If the person doesn't have permission, don't return IPs - return new GsonBuilder().registerTypeAdapter(ZonedDateTime.class, new ZonedDateTimeAdapter()).setPrettyPrinting().create().toJson(punishedPlayer.getPunishments().stream().peek(punishment -> punishment.setIp("")).toList()); - } + AuthenticatedUser viewer = currentStaff(request); response.setHeader("content-type", "application/json"); + if (viewer == null) + { + return new GsonBuilder().registerTypeAdapter(ZonedDateTime.class, new ZonedDateTimeAdapter()).setPrettyPrinting().create().toJson(punishedPlayer.getPunishments().stream().peek(punishment -> punishment.setIp("")).toList()); + } return new GsonBuilder().registerTypeAdapter(ZonedDateTime.class, new ZonedDateTimeAdapter()).setPrettyPrinting().create().toJson(punishedPlayer.getPunishments().stream().toList()); } diff --git a/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java b/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java index 8a0b2a6..638b4d7 100644 --- a/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java +++ b/src/main/java/dev/plex/request/impl/PunishmentsUIEndpoint.java @@ -1,6 +1,5 @@ package dev.plex.request.impl; -import dev.plex.HTTPDModule; import dev.plex.Plex; import dev.plex.cache.DataUtils; import dev.plex.player.PlexPlayer; @@ -16,9 +15,6 @@ import java.time.format.DateTimeFormatter; import java.util.List; import java.util.UUID; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; - public class PunishmentsUIEndpoint extends AbstractServlet { private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z"); @@ -50,7 +46,7 @@ public class PunishmentsUIEndpoint extends AbstractServlet return goodHTML(escapeHtml(punished.getName()) + " has no punishments on record."); } - boolean showIps = canViewIps(request.getRemoteAddr()); + boolean showIps = currentStaff(request) != null; return resultsHTML(punished, punishments, showIps); } @@ -66,15 +62,6 @@ public class PunishmentsUIEndpoint extends AbstractServlet } } - private static boolean canViewIps(String requesterIp) - { - if (requesterIp == null) return false; - PlexPlayer viewer = DataUtils.getPlayerByIP(requesterIp); - if (viewer == null) return false; - OfflinePlayer offline = Bukkit.getOfflinePlayer(viewer.getUuid()); - return HTTPDModule.getPermissions().playerHas(null, offline, "plex.httpd.punishments.access"); - } - private String resultsHTML(PlexPlayer player, List punishments, boolean showIps) { StringBuilder cards = new StringBuilder(); diff --git a/src/main/java/dev/plex/request/impl/SchematicDownloadEndpoint.java b/src/main/java/dev/plex/request/impl/SchematicDownloadEndpoint.java index 7661969..78b7281 100644 --- a/src/main/java/dev/plex/request/impl/SchematicDownloadEndpoint.java +++ b/src/main/java/dev/plex/request/impl/SchematicDownloadEndpoint.java @@ -1,6 +1,8 @@ package dev.plex.request.impl; import dev.plex.HTTPDModule; +import dev.plex.authentication.AuthenticatedUser; +import dev.plex.logging.Log; import dev.plex.request.AbstractServlet; import dev.plex.request.GetMapping; import dev.plex.util.PlexLog; @@ -36,12 +38,12 @@ public class SchematicDownloadEndpoint extends AbstractServlet { return null; } - schematicServe(request.getPathInfo().replace("/", ""), outputStream); + schematicServe(request, request.getPathInfo().replace("/", ""), outputStream); return null; } } - private void schematicServe(String requestedSchematic, OutputStream outputStream) + private void schematicServe(HttpServletRequest request, String requestedSchematic, OutputStream outputStream) { File worldeditFolder = HTTPDModule.getWorldeditFolder(); if (worldeditFolder == null) @@ -60,6 +62,7 @@ public class SchematicDownloadEndpoint extends AbstractServlet if (schemData != null) { outputStream.write(schemData); + logDownload(request, schemFile); } } catch (IOException ignored) @@ -69,6 +72,14 @@ public class SchematicDownloadEndpoint extends AbstractServlet } } + private static void logDownload(HttpServletRequest request, File schemFile) + { + AuthenticatedUser user = currentUser(request); + String who = user != null ? user.username() + " (xf:" + user.userId() + ")" : request.getRemoteAddr(); + PlexLog.log("{0} downloaded schematic {1}", who, schemFile.getName()); + Log.log("{0} downloaded schematic {1}", who, schemFile.getName()); + } + private String schematicHTML() { String file = readFile(this.getClass().getResourceAsStream("/httpd/schematic_download.html")); diff --git a/src/main/java/dev/plex/request/impl/SchematicUploadEndpoint.java b/src/main/java/dev/plex/request/impl/SchematicUploadEndpoint.java index 5705d65..1340f8a 100644 --- a/src/main/java/dev/plex/request/impl/SchematicUploadEndpoint.java +++ b/src/main/java/dev/plex/request/impl/SchematicUploadEndpoint.java @@ -1,36 +1,20 @@ package dev.plex.request.impl; -import dev.plex.HTTPDModule; -import dev.plex.cache.DataUtils; -import dev.plex.player.PlexPlayer; +import dev.plex.authentication.AuthenticatedUser; import dev.plex.request.AbstractServlet; import dev.plex.request.GetMapping; -import dev.plex.util.PlexLog; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; public class SchematicUploadEndpoint extends AbstractServlet { @GetMapping(endpoint = "/api/schematics/upload/") public String uploadSchematic(HttpServletRequest request, HttpServletResponse response) { - String ipAddress = request.getRemoteAddr(); - if (ipAddress == null) + AuthenticatedUser user = currentStaff(request); + if (user == null) { - return schematicsHTML("An IP address could not be detected. Please ensure you are connecting using IPv4."); - } - final PlexPlayer player = DataUtils.getPlayerByIP(ipAddress); - if (player == null) - { - return schematicsHTML("Couldn't load your IP Address: " + ipAddress + ". Have you joined the server before?"); - } - PlexLog.debug("Plex-HTTPD using permissions check"); - final OfflinePlayer offlinePlayer = Bukkit.getOfflinePlayer(player.getUuid()); - if (!HTTPDModule.getPermissions().playerHas(null, offlinePlayer, "plex.httpd.schematics.upload")) - { - return schematicsHTML("You do not have permission to upload schematics."); + return schematicsHTML(signInPrompt("to upload schematics")); } return readFile(this.getClass().getResourceAsStream("/httpd/schematic_upload.html")); } diff --git a/src/main/resources/httpd/config.yml b/src/main/resources/httpd/config.yml index 708e8c2..6b59bbf 100644 --- a/src/main/resources/httpd/config.yml +++ b/src/main/resources/httpd/config.yml @@ -1,15 +1,35 @@ server: bind-address: 0.0.0.0 port: 27192 - logging: false + # Logging. The HTTPD writes one line per request and rate-limit event. + logging: + file: true # write to /httpd.log + file-path: "httpd.log" # relative to the module's data folder + console: false # also mirror to the Bukkit console + +# Token-bucket rate limiting. Defaults are sized for a small sized server. +# capacity = burst, per-second = sustained rate. Disable globally with enabled: false. +rate-limit: + enabled: true + global: + capacity: 200 + per-second: 100 + per-ip: + capacity: 30 + per-second: 10 authentication: enabled: false - # Providers: discord provider: - name: discord - redirectUri: "" - discord: # Fill if using discord provider + # Absolute URL the XenForo authorize page will redirect back to. + # Must match the redirect URI registered on the OAuth2 client in XenForo. + redirectUri: "https://example.com/oauth2/callback" + xenforo: + # XenForo site domain (no scheme, no trailing slash). The /oauth2/authorize, + # /api/oauth2/token, /api/oauth2/revoke, and /api/me paths are derived from it. + domain: "totalfreedom.tf" clientId: "" - token: "" # Can also use environment variable or system property BOT_TOKEN \ No newline at end of file + clientSecret: "" + # How long a successful login stays valid, in minutes. + sessionMinutes: 1440 diff --git a/src/main/resources/httpd/template.html b/src/main/resources/httpd/template.html index a10e5d5..6a02dec 100644 --- a/src/main/resources/httpd/template.html +++ b/src/main/resources/httpd/template.html @@ -324,7 +324,8 @@ -