Replace IP-based auth with XenForo OAuth2

Also, resolves the long standing issues #2 and #3
This commit is contained in:
2026-05-17 19:06:39 -04:00
parent a92be6c681
commit 94cb2a98c4
24 changed files with 870 additions and 343 deletions
-10
View File
@@ -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")
-1
View File
@@ -15,4 +15,3 @@ pluginManagement {
}
rootProject.name = "Module-HTTPD"
+19 -26
View File
@@ -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<Server> 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<Permission> rsp = Bukkit.getServicesManager().getRegistration(Permission.class);
if (rsp != null) {
permissions = rsp.getProvider();
}
return permissions != null;
Log.shutdown();
}
public static File getWorldeditFolder()
@@ -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<String> 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;
}
@@ -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);
}
}
@@ -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()
@@ -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<String, AuthenticatedUser> 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);
}
@@ -2,5 +2,5 @@ package dev.plex.authentication;
public enum UserType
{
DISCORD, UNKNOWN
XENFORO, UNKNOWN
}
@@ -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<String, AuthenticatedUser> 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<String, AuthenticatedUser> 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);
}
}
@@ -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<String, PendingLogin> pending = new ConcurrentHashMap<>();
private final Map<String, AuthenticatedUser> 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<String> 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<String> 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) {}
}
+77 -7
View File
@@ -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)
{
for (int i = 0; i < strings.length; i++)
String formatted = format(message, strings);
writeFile(formatted);
if (HTTPDModule.moduleConfig != null && HTTPDModule.moduleConfig.getBoolean("server.logging.console", false))
{
if (message.contains("{" + i + "}"))
{
message = message.replace("{" + i + "}", strings[i].toString());
Bukkit.getConsoleSender().sendMessage(Component.text("[Plex HTTPD] ").color(NamedTextColor.DARK_AQUA).append(Component.text(formatted).color(NamedTextColor.GRAY)));
}
}
if (HTTPDModule.moduleConfig.getBoolean("server.logging"))
public static synchronized void shutdown()
{
Bukkit.getConsoleSender().sendMessage(Component.text("[Plex HTTPD] ").color(NamedTextColor.DARK_AQUA).append(Component.text(message).color(NamedTextColor.GRAY)));
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++)
{
String token = "{" + i + "}";
if (message.contains(token))
{
message = message.replace(token, strings[i] == null ? "null" : strings[i].toString());
}
}
return message;
}
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))
{
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());
}
}
}
@@ -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<String, TokenBucket> 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;
}
}
@@ -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;
}
}
@@ -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 <a class=\"text-primary underline\" href=\"/oauth2/login\">sign in</a> as staff " + action + ".";
}
public static String readFile(InputStream filename)
{
String base = HTTPDModule.template;
@@ -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 <b>" + filename + "</b>."));
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)
@@ -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 "<!doctype html><meta charset=utf-8><title>Sign-in failed</title>"
+ "<body style=\"font-family:system-ui;padding:2rem;max-width:30rem;margin:auto\">"
+ "<h1 style=\"font-size:1.25rem\">Sign-in failed</h1>"
+ "<p>" + escape(e.getMessage()) + "</p>"
+ "<p><a href=\"/oauth2/login\">Try again</a></p>";
}
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}
@@ -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");
@@ -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 <span class=\"font-mono text-foreground\">plex.httpd.indefbans.access</span>.");
return errorHTML(signInPrompt("to view this page"));
}
List<IndefiniteBan> 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())
? "<span class=\"italic text-muted-foreground/70\">No reason provided</span>"
: 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 """
<article class="ring-card rounded-2xl bg-card p-5">
<header class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-sm">%s</p>
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">%d %s</span>
</header>
<div class="mt-4 flex flex-wrap gap-1.5">
<dl class="mt-4 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 border-t border-border/60 pt-3 font-mono text-[11px]">
%s
</div>
</dl>
</article>
""".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<String> 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("<span>").append(value).append("</span>");
}
return """
<span class="inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 font-mono text-xs %s">
<span class="text-[9px] uppercase tracking-wider opacity-60">%s</span>
<span>%s</span>
</span>
""".formatted(color, label, value);
<dt class="text-muted-foreground uppercase tracking-wider">%s</dt>
<dd class="flex flex-wrap gap-x-3 gap-y-1 %s">%s</dd>
""".formatted(label, valueClasses, items);
}
private String errorHTML(String message)
@@ -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());
}
@@ -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<Punishment> punishments, boolean showIps)
{
StringBuilder cards = new StringBuilder();
@@ -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"));
@@ -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"));
}
+26 -6
View File
@@ -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 <data-folder>/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
clientSecret: ""
# How long a successful login stays valid, in minutes.
sessionMinutes: 1440
+20 -1
View File
@@ -324,7 +324,8 @@
</a>
</nav>
<div class="hidden items-center gap-2 md:flex">
<div class="flex items-center gap-2">
<div id="plex-auth" class="hidden md:flex items-center gap-2"></div>
<button type="button" onclick="window.plexToggleTheme()" class="ring-card inline-flex size-8 items-center justify-center rounded-full bg-card text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" aria-label="Toggle theme">
<svg class="size-4 hidden dark:block" aria-hidden="true"><use href="#i-sun"/></svg>
<svg class="size-4 block dark:hidden" aria-hidden="true"><use href="#i-moon"/></svg>
@@ -347,6 +348,24 @@
document.querySelectorAll('.nav-link').forEach(a => {
if (a.classList.contains('active')) a.setAttribute('data-active', 'true');
});
(function () {
const mount = document.getElementById('plex-auth');
if (!mount) return;
const linkClasses = 'ring-card inline-flex h-8 items-center gap-1.5 rounded-full bg-card px-3 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground';
const escape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
fetch('/oauth2/me', { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
.then(r => r.json().catch(() => ({})).then(j => ({ status: r.status, body: j })))
.then(({ status, body }) => {
if (body && body.authenticated === false && body.reason === 'disabled') return;
if (status === 200 && body.authenticated) {
mount.innerHTML = '<span class="text-xs text-muted-foreground">' + escape(body.username) + '</span>'
+ '<a href="/oauth2/logout" class="' + linkClasses + '">Sign out</a>';
} else {
mount.innerHTML = '<a href="/oauth2/login" class="' + linkClasses + '">Sign in</a>';
}
})
.catch(() => {});
})();
</script>
</body>