mirror of
https://github.com/plexusorg/Module-HTTPD.git
synced 2026-06-04 00:56:54 +00:00
Replace IP-based auth with XenForo OAuth2
Also, resolves the long standing issues #2 and #3
This commit is contained in:
@@ -20,13 +20,6 @@ repositories {
|
|||||||
url = uri("https://nexus.telesphoreo.me/repository/plex/")
|
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/") }
|
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:jetty-server:12.1.9")
|
||||||
plexLibrary("org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.9")
|
plexLibrary("org.eclipse.jetty.ee10:jetty-ee10-servlet:12.1.9")
|
||||||
plexLibrary("org.eclipse.jetty:jetty-proxy: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
|
implementation(platform("com.intellectualsites.bom:bom-newest:1.56")) // Ref: https://github.com/IntellectualSites/bom
|
||||||
compileOnly("com.fastasyncworldedit:FastAsyncWorldEdit-Core")
|
compileOnly("com.fastasyncworldedit:FastAsyncWorldEdit-Core")
|
||||||
implementation("commons-io:commons-io:2.22.0")
|
implementation("commons-io:commons-io:2.22.0")
|
||||||
|
|||||||
@@ -15,4 +15,3 @@ pluginManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "Module-HTTPD"
|
rootProject.name = "Module-HTTPD"
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,25 @@ package dev.plex;
|
|||||||
import dev.plex.authentication.AuthenticationManager;
|
import dev.plex.authentication.AuthenticationManager;
|
||||||
import dev.plex.cache.FileCache;
|
import dev.plex.cache.FileCache;
|
||||||
import dev.plex.config.ModuleConfig;
|
import dev.plex.config.ModuleConfig;
|
||||||
|
import dev.plex.logging.Log;
|
||||||
import dev.plex.module.PlexModule;
|
import dev.plex.module.PlexModule;
|
||||||
|
import dev.plex.ratelimit.RateLimitFilter;
|
||||||
import dev.plex.request.AbstractServlet;
|
import dev.plex.request.AbstractServlet;
|
||||||
import dev.plex.request.SchematicUploadServlet;
|
import dev.plex.request.SchematicUploadServlet;
|
||||||
import dev.plex.request.impl.*;
|
import dev.plex.request.impl.*;
|
||||||
import dev.plex.util.PlexLog;
|
import dev.plex.util.PlexLog;
|
||||||
|
import jakarta.servlet.DispatcherType;
|
||||||
import jakarta.servlet.MultipartConfigElement;
|
import jakarta.servlet.MultipartConfigElement;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import net.milkbowl.vault.permission.Permission;
|
|
||||||
import org.bukkit.Bukkit;
|
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.ServletContextHandler;
|
||||||
import org.eclipse.jetty.ee10.servlet.ServletHandler;
|
import org.eclipse.jetty.ee10.servlet.ServletHandler;
|
||||||
import org.eclipse.jetty.ee10.servlet.ServletHolder;
|
import org.eclipse.jetty.ee10.servlet.ServletHolder;
|
||||||
import org.eclipse.jetty.server.*;
|
import org.eclipse.jetty.server.*;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.util.EnumSet;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
public class HTTPDModule extends PlexModule
|
public class HTTPDModule extends PlexModule
|
||||||
@@ -27,16 +30,17 @@ public class HTTPDModule extends PlexModule
|
|||||||
private Thread serverThread;
|
private Thread serverThread;
|
||||||
private AtomicReference<Server> atomicServer = new AtomicReference<>();
|
private AtomicReference<Server> atomicServer = new AtomicReference<>();
|
||||||
|
|
||||||
@Getter
|
|
||||||
private static Permission permissions = null;
|
|
||||||
|
|
||||||
public static ModuleConfig moduleConfig;
|
public static ModuleConfig moduleConfig;
|
||||||
|
|
||||||
public static final FileCache fileCache = new FileCache();
|
public static final FileCache fileCache = new FileCache();
|
||||||
|
|
||||||
public static final String template = AbstractServlet.readFileReal(HTTPDModule.class.getResourceAsStream("/httpd/template.html"));
|
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
|
@Override
|
||||||
public void load()
|
public void load()
|
||||||
@@ -50,19 +54,13 @@ public class HTTPDModule extends PlexModule
|
|||||||
{
|
{
|
||||||
moduleConfig.load();
|
moduleConfig.load();
|
||||||
PlexLog.debug("HTTPD Module Port: {0}", moduleConfig.getInt("server.port"));
|
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();
|
accessLogFile = new File(getDataFolder(), moduleConfig.getString("server.logging.file-path", "httpd.log"));
|
||||||
if (this.authenticationManager.provider() != null)
|
|
||||||
|
authenticationManager = new AuthenticationManager();
|
||||||
|
if (authenticationManager.provider() == null)
|
||||||
{
|
{
|
||||||
PlexLog.debug(this.authenticationManager.provider().generateLogin());
|
PlexLog.debug("Authentication is disabled or misconfigured");
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
PlexLog.debug("Provider was not found for Authentication so disabled");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +79,8 @@ public class HTTPDModule extends PlexModule
|
|||||||
connector.setHost(moduleConfig.getString("server.bind-address"));
|
connector.setHost(moduleConfig.getString("server.bind-address"));
|
||||||
connector.setPort(moduleConfig.getInt("server.port"));
|
connector.setPort(moduleConfig.getInt("server.port"));
|
||||||
|
|
||||||
|
context.addFilter(new FilterHolder(new RateLimitFilter()), "/*", EnumSet.of(DispatcherType.REQUEST));
|
||||||
|
|
||||||
new IndefBansEndpoint();
|
new IndefBansEndpoint();
|
||||||
new IndexEndpoint();
|
new IndexEndpoint();
|
||||||
new ListEndpoint();
|
new ListEndpoint();
|
||||||
@@ -93,6 +93,7 @@ public class HTTPDModule extends PlexModule
|
|||||||
new AssetsEndpoint();
|
new AssetsEndpoint();
|
||||||
new PunishmentsUIEndpoint();
|
new PunishmentsUIEndpoint();
|
||||||
new IndefBansUIEndpoint();
|
new IndefBansUIEndpoint();
|
||||||
|
new AuthenticationEndpoint();
|
||||||
|
|
||||||
ServletHolder uploadHolder = HTTPDModule.context.addServlet(SchematicUploadServlet.class, "/api/schematics/uploading");
|
ServletHolder uploadHolder = HTTPDModule.context.addServlet(SchematicUploadServlet.class, "/api/schematics/uploading");
|
||||||
|
|
||||||
@@ -134,15 +135,7 @@ public class HTTPDModule extends PlexModule
|
|||||||
{
|
{
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
}
|
Log.shutdown();
|
||||||
|
|
||||||
private boolean setupPermissions()
|
|
||||||
{
|
|
||||||
RegisteredServiceProvider<Permission> rsp = Bukkit.getServicesManager().getRegistration(Permission.class);
|
|
||||||
if (rsp != null) {
|
|
||||||
permissions = rsp.getProvider();
|
|
||||||
}
|
|
||||||
return permissions != null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static File getWorldeditFolder()
|
public static File getWorldeditFolder()
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
package dev.plex.authentication;
|
package dev.plex.authentication;
|
||||||
|
|
||||||
import com.google.common.collect.Lists;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.experimental.Accessors;
|
import lombok.experimental.Accessors;
|
||||||
|
|
||||||
import java.time.ZonedDateTime;
|
import java.time.Instant;
|
||||||
import java.util.LinkedList;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Accessors(fluent = true)
|
@Accessors(fluent = true)
|
||||||
public class AuthenticatedUser
|
public class AuthenticatedUser
|
||||||
{
|
{
|
||||||
private final String ip;
|
private final int userId;
|
||||||
private final ZonedDateTime lastAuthenticated;
|
private final String username;
|
||||||
private final LinkedList<String> roles = Lists.newLinkedList();
|
private final boolean staff;
|
||||||
private final UserType userType = UserType.UNKNOWN;
|
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;
|
package dev.plex.authentication;
|
||||||
|
|
||||||
import dev.plex.HTTPDModule;
|
import dev.plex.HTTPDModule;
|
||||||
import dev.plex.authentication.impl.DiscordOAuth2Provider;
|
import dev.plex.authentication.impl.XenForoOAuth2Provider;
|
||||||
import dev.plex.util.PlexLog;
|
import dev.plex.util.PlexLog;
|
||||||
import org.apache.commons.lang3.NotImplementedException;
|
|
||||||
|
|
||||||
public class AuthenticationManager
|
public class AuthenticationManager
|
||||||
{
|
{
|
||||||
@@ -18,32 +17,8 @@ public class AuthenticationManager
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PlexLog.debug("[HTTPD] Auth is enabled");
|
PlexLog.log("[HTTPD] XenForo OAuth2 authentication is enabled");
|
||||||
|
provider = new XenForoOAuth2Provider();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public OAuth2Provider provider()
|
public OAuth2Provider provider()
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
package dev.plex.authentication;
|
package dev.plex.authentication;
|
||||||
|
|
||||||
import org.eclipse.jetty.server.Response;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import java.util.HashMap;
|
|
||||||
|
|
||||||
public interface OAuth2Provider
|
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
|
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) {}
|
||||||
|
}
|
||||||
@@ -4,23 +4,93 @@ import dev.plex.HTTPDModule;
|
|||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
import net.kyori.adventure.text.format.NamedTextColor;
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
import org.bukkit.Bukkit;
|
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
|
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)
|
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++)
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 com.google.common.collect.Lists;
|
||||||
import dev.plex.HTTPDModule;
|
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 dev.plex.logging.Log;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServlet;
|
import jakarta.servlet.http.HttpServlet;
|
||||||
@@ -133,6 +136,26 @@ public class AbstractServlet extends HttpServlet
|
|||||||
return requestPath.isEmpty() ? "/" : requestPath;
|
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)
|
public static String readFile(InputStream filename)
|
||||||
{
|
{
|
||||||
String base = HTTPDModule.template;
|
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.ClipboardFormat;
|
||||||
import com.sk89q.worldedit.extent.clipboard.io.ClipboardFormats;
|
import com.sk89q.worldedit.extent.clipboard.io.ClipboardFormats;
|
||||||
import dev.plex.HTTPDModule;
|
import dev.plex.HTTPDModule;
|
||||||
import dev.plex.cache.DataUtils;
|
import dev.plex.authentication.AuthenticatedUser;
|
||||||
import dev.plex.player.PlexPlayer;
|
import dev.plex.logging.Log;
|
||||||
import dev.plex.util.PlexLog;
|
import dev.plex.util.PlexLog;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServlet;
|
import jakarta.servlet.http.HttpServlet;
|
||||||
@@ -12,8 +12,6 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.servlet.http.Part;
|
import jakarta.servlet.http.Part;
|
||||||
import org.apache.commons.io.FileUtils;
|
import org.apache.commons.io.FileUtils;
|
||||||
import org.bukkit.Bukkit;
|
|
||||||
import org.bukkit.OfflinePlayer;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
@@ -29,22 +27,10 @@ public class SchematicUploadServlet extends HttpServlet
|
|||||||
@Override
|
@Override
|
||||||
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException
|
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."));
|
response.getWriter().println(schematicUploadBadHTML(AbstractServlet.signInPrompt("to upload schematics")));
|
||||||
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."));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
File worldeditFolder = HTTPDModule.getWorldeditFolder();
|
File worldeditFolder = HTTPDModule.getWorldeditFolder();
|
||||||
@@ -82,7 +68,8 @@ public class SchematicUploadServlet extends HttpServlet
|
|||||||
ClipboardFormat schematicFormat = ClipboardFormats.findByFile(schematicFile);
|
ClipboardFormat schematicFormat = ClipboardFormats.findByFile(schematicFile);
|
||||||
if (schematicFormat == null)
|
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."));
|
response.getWriter().println(schematicUploadBadHTML("Schematic is not a valid format."));
|
||||||
FileUtils.deleteQuietly(schematicFile);
|
FileUtils.deleteQuietly(schematicFile);
|
||||||
return;
|
return;
|
||||||
@@ -93,15 +80,16 @@ public class SchematicUploadServlet extends HttpServlet
|
|||||||
}
|
}
|
||||||
catch (IOException e)
|
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."));
|
response.getWriter().println(schematicUploadBadHTML("Schematic is not a valid format."));
|
||||||
FileUtils.deleteQuietly(schematicFile);
|
FileUtils.deleteQuietly(schematicFile);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Files.copy(inputStream, schematic.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
inputStream.close();
|
inputStream.close();
|
||||||
response.getWriter().println(schematicUploadGoodHTML("Successfully uploaded <b>" + filename + "</b>."));
|
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)
|
private String schematicUploadBadHTML(String message)
|
||||||
|
|||||||
@@ -1,27 +1,124 @@
|
|||||||
package dev.plex.request.impl;
|
package dev.plex.request.impl;
|
||||||
|
|
||||||
import dev.plex.command.PlexCommand;
|
import dev.plex.HTTPDModule;
|
||||||
import dev.plex.command.annotation.CommandPermissions;
|
import dev.plex.authentication.AuthenticatedUser;
|
||||||
|
import dev.plex.authentication.AuthenticationException;
|
||||||
|
import dev.plex.authentication.OAuth2Provider;
|
||||||
import dev.plex.request.AbstractServlet;
|
import dev.plex.request.AbstractServlet;
|
||||||
import dev.plex.request.GetMapping;
|
import dev.plex.request.GetMapping;
|
||||||
|
import dev.plex.request.MappingHeaders;
|
||||||
import dev.plex.util.PlexLog;
|
import dev.plex.util.PlexLog;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.bukkit.Bukkit;
|
import org.json.JSONObject;
|
||||||
import org.bukkit.command.Command;
|
|
||||||
import org.bukkit.command.CommandMap;
|
|
||||||
import org.bukkit.command.PluginIdentifiableCommand;
|
|
||||||
|
|
||||||
import java.util.*;
|
import java.io.IOException;
|
||||||
|
|
||||||
public class AuthenticationEndpoint extends AbstractServlet
|
public class AuthenticationEndpoint extends AbstractServlet
|
||||||
{
|
{
|
||||||
|
@GetMapping(endpoint = "/oauth2/login")
|
||||||
|
public String login(HttpServletRequest request, HttpServletResponse response) throws IOException
|
||||||
@GetMapping(endpoint = "/oauth2")
|
|
||||||
public String login(HttpServletRequest request, HttpServletResponse response)
|
|
||||||
{
|
{
|
||||||
// TODO: Nuh uh
|
OAuth2Provider provider = HTTPDModule.getAuthenticationManager().provider();
|
||||||
return "";
|
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("&", "&").replace("<", "<").replace(">", ">");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,22 @@
|
|||||||
package dev.plex.request.impl;
|
package dev.plex.request.impl;
|
||||||
|
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import dev.plex.HTTPDModule;
|
|
||||||
import dev.plex.Plex;
|
import dev.plex.Plex;
|
||||||
import dev.plex.cache.DataUtils;
|
import dev.plex.authentication.AuthenticatedUser;
|
||||||
import dev.plex.player.PlexPlayer;
|
|
||||||
import dev.plex.request.AbstractServlet;
|
import dev.plex.request.AbstractServlet;
|
||||||
import dev.plex.request.GetMapping;
|
import dev.plex.request.GetMapping;
|
||||||
import dev.plex.util.PlexLog;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.bukkit.Bukkit;
|
|
||||||
import org.bukkit.OfflinePlayer;
|
|
||||||
|
|
||||||
public class IndefBansEndpoint extends AbstractServlet
|
public class IndefBansEndpoint extends AbstractServlet
|
||||||
{
|
{
|
||||||
private static final String TITLE = "Indefinite Bans - Plex HTTPD";
|
|
||||||
|
|
||||||
@GetMapping(endpoint = "/api/indefbans/")
|
@GetMapping(endpoint = "/api/indefbans/")
|
||||||
public String getBans(HttpServletRequest request, HttpServletResponse response)
|
public String getBans(HttpServletRequest request, HttpServletResponse response)
|
||||||
{
|
{
|
||||||
String ipAddress = request.getRemoteAddr();
|
AuthenticatedUser user = currentStaff(request);
|
||||||
if (ipAddress == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
return indefbansHTML("An IP address could not be detected. Please ensure you are connecting using IPv4.");
|
return indefbansHTML(signInPrompt("to view this page"));
|
||||||
}
|
|
||||||
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.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response.setHeader("content-type", "application/json");
|
response.setHeader("content-type", "application/json");
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package dev.plex.request.impl;
|
package dev.plex.request.impl;
|
||||||
|
|
||||||
import dev.plex.HTTPDModule;
|
|
||||||
import dev.plex.Plex;
|
import dev.plex.Plex;
|
||||||
import dev.plex.cache.DataUtils;
|
import dev.plex.authentication.AuthenticatedUser;
|
||||||
import dev.plex.player.PlexPlayer;
|
|
||||||
import dev.plex.punishment.PunishmentManager.IndefiniteBan;
|
import dev.plex.punishment.PunishmentManager.IndefiniteBan;
|
||||||
import dev.plex.request.AbstractServlet;
|
import dev.plex.request.AbstractServlet;
|
||||||
import dev.plex.request.GetMapping;
|
import dev.plex.request.GetMapping;
|
||||||
@@ -13,28 +11,15 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.bukkit.Bukkit;
|
|
||||||
import org.bukkit.OfflinePlayer;
|
|
||||||
|
|
||||||
public class IndefBansUIEndpoint extends AbstractServlet
|
public class IndefBansUIEndpoint extends AbstractServlet
|
||||||
{
|
{
|
||||||
@GetMapping(endpoint = "/indefbans/")
|
@GetMapping(endpoint = "/indefbans/")
|
||||||
public String getBans(HttpServletRequest request, HttpServletResponse response)
|
public String getBans(HttpServletRequest request, HttpServletResponse response)
|
||||||
{
|
{
|
||||||
String ipAddress = request.getRemoteAddr();
|
AuthenticatedUser viewer = currentStaff(request);
|
||||||
if (ipAddress == null)
|
|
||||||
{
|
|
||||||
return errorHTML("Cannot detect an IP address on this request.");
|
|
||||||
}
|
|
||||||
PlexPlayer viewer = DataUtils.getPlayerByIP(ipAddress);
|
|
||||||
if (viewer == null)
|
if (viewer == null)
|
||||||
{
|
{
|
||||||
return errorHTML("This IP (" + escapeHtml(ipAddress) + ") is not linked to a known player.");
|
return errorHTML(signInPrompt("to view this page"));
|
||||||
}
|
|
||||||
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>.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<IndefiniteBan> bans = Plex.get().getPunishmentManager().getIndefiniteBans();
|
List<IndefiniteBan> bans = Plex.get().getPunishmentManager().getIndefiniteBans();
|
||||||
@@ -72,59 +57,50 @@ public class IndefBansUIEndpoint extends AbstractServlet
|
|||||||
|
|
||||||
private static String renderCard(IndefiniteBan ban)
|
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())
|
String reason = (ban.getReason() == null || ban.getReason().isBlank())
|
||||||
? "<span class=\"italic text-muted-foreground/70\">No reason provided</span>"
|
? "<span class=\"italic text-muted-foreground/70\">No reason provided</span>"
|
||||||
: escapeHtml(ban.getReason());
|
: escapeHtml(ban.getReason());
|
||||||
|
|
||||||
int total = ban.getUsernames().size() + ban.getUuids().size() + ban.getIps().size();
|
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 """
|
return """
|
||||||
<article class="ring-card rounded-2xl bg-card p-5">
|
<article class="ring-card rounded-2xl bg-card p-5">
|
||||||
<header class="flex flex-wrap items-baseline justify-between gap-3">
|
<header class="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
<p class="text-sm">%s</p>
|
<p class="text-sm">%s</p>
|
||||||
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">%d %s</span>
|
<span class="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">%d %s</span>
|
||||||
</header>
|
</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
|
%s
|
||||||
</div>
|
</dl>
|
||||||
</article>
|
</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";
|
items.append("<span>").append(value).append("</span>");
|
||||||
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 -> "";
|
|
||||||
};
|
|
||||||
return """
|
return """
|
||||||
<span class="inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 font-mono text-xs %s">
|
<dt class="text-muted-foreground uppercase tracking-wider">%s</dt>
|
||||||
<span class="text-[9px] uppercase tracking-wider opacity-60">%s</span>
|
<dd class="flex flex-wrap gap-x-3 gap-y-1 %s">%s</dd>
|
||||||
<span>%s</span>
|
""".formatted(label, valueClasses, items);
|
||||||
</span>
|
|
||||||
""".formatted(color, label, value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String errorHTML(String message)
|
private String errorHTML(String message)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package dev.plex.request.impl;
|
package dev.plex.request.impl;
|
||||||
|
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import dev.plex.HTTPDModule;
|
import dev.plex.authentication.AuthenticatedUser;
|
||||||
import dev.plex.cache.DataUtils;
|
import dev.plex.cache.DataUtils;
|
||||||
import dev.plex.player.PlexPlayer;
|
import dev.plex.player.PlexPlayer;
|
||||||
import dev.plex.request.AbstractServlet;
|
import dev.plex.request.AbstractServlet;
|
||||||
@@ -9,40 +9,31 @@ import dev.plex.request.GetMapping;
|
|||||||
import dev.plex.util.adapter.ZonedDateTimeAdapter;
|
import dev.plex.util.adapter.ZonedDateTimeAdapter;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.bukkit.Bukkit;
|
|
||||||
import org.bukkit.OfflinePlayer;
|
|
||||||
|
|
||||||
public class PunishmentsEndpoint extends AbstractServlet
|
public class PunishmentsEndpoint extends AbstractServlet
|
||||||
{
|
{
|
||||||
@GetMapping(endpoint = "/api/punishments/")
|
@GetMapping(endpoint = "/api/punishments/")
|
||||||
public String getPunishments(HttpServletRequest request, HttpServletResponse response)
|
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("/"))
|
if (request.getPathInfo() == null || request.getPathInfo().equals("/"))
|
||||||
{
|
{
|
||||||
return readFile(this.getClass().getResourceAsStream("/httpd/punishments.html"));
|
return readFile(this.getClass().getResourceAsStream("/httpd/punishments.html"));
|
||||||
}
|
}
|
||||||
UUID pathUUID;
|
|
||||||
String pathPlexPlayer;
|
|
||||||
PlexPlayer punishedPlayer;
|
PlexPlayer punishedPlayer;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
pathUUID = UUID.fromString(request.getPathInfo().replace("/", ""));
|
UUID pathUUID = UUID.fromString(request.getPathInfo().replace("/", ""));
|
||||||
punishedPlayer = DataUtils.getPlayer(pathUUID);
|
punishedPlayer = DataUtils.getPlayer(pathUUID);
|
||||||
}
|
}
|
||||||
catch (java.lang.IllegalArgumentException ignored)
|
catch (IllegalArgumentException ignored)
|
||||||
{
|
{
|
||||||
pathPlexPlayer = request.getPathInfo().replace("/", "");
|
punishedPlayer = DataUtils.getPlayer(request.getPathInfo().replace("/", ""));
|
||||||
punishedPlayer = DataUtils.getPlayer(pathPlexPlayer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final PlexPlayer player = DataUtils.getPlayerByIP(ipAddress);
|
|
||||||
if (punishedPlayer == null)
|
if (punishedPlayer == null)
|
||||||
{
|
{
|
||||||
return punishmentsHTML("This player has never joined the server before.");
|
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!");
|
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");
|
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());
|
return new GsonBuilder().registerTypeAdapter(ZonedDateTime.class, new ZonedDateTimeAdapter()).setPrettyPrinting().create().toJson(punishedPlayer.getPunishments().stream().toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package dev.plex.request.impl;
|
package dev.plex.request.impl;
|
||||||
|
|
||||||
import dev.plex.HTTPDModule;
|
|
||||||
import dev.plex.Plex;
|
import dev.plex.Plex;
|
||||||
import dev.plex.cache.DataUtils;
|
import dev.plex.cache.DataUtils;
|
||||||
import dev.plex.player.PlexPlayer;
|
import dev.plex.player.PlexPlayer;
|
||||||
@@ -16,9 +15,6 @@ import java.time.format.DateTimeFormatter;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.bukkit.Bukkit;
|
|
||||||
import org.bukkit.OfflinePlayer;
|
|
||||||
|
|
||||||
public class PunishmentsUIEndpoint extends AbstractServlet
|
public class PunishmentsUIEndpoint extends AbstractServlet
|
||||||
{
|
{
|
||||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z");
|
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.");
|
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);
|
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)
|
private String resultsHTML(PlexPlayer player, List<Punishment> punishments, boolean showIps)
|
||||||
{
|
{
|
||||||
StringBuilder cards = new StringBuilder();
|
StringBuilder cards = new StringBuilder();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package dev.plex.request.impl;
|
package dev.plex.request.impl;
|
||||||
|
|
||||||
import dev.plex.HTTPDModule;
|
import dev.plex.HTTPDModule;
|
||||||
|
import dev.plex.authentication.AuthenticatedUser;
|
||||||
|
import dev.plex.logging.Log;
|
||||||
import dev.plex.request.AbstractServlet;
|
import dev.plex.request.AbstractServlet;
|
||||||
import dev.plex.request.GetMapping;
|
import dev.plex.request.GetMapping;
|
||||||
import dev.plex.util.PlexLog;
|
import dev.plex.util.PlexLog;
|
||||||
@@ -36,12 +38,12 @@ public class SchematicDownloadEndpoint extends AbstractServlet
|
|||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
schematicServe(request.getPathInfo().replace("/", ""), outputStream);
|
schematicServe(request, request.getPathInfo().replace("/", ""), outputStream);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void schematicServe(String requestedSchematic, OutputStream outputStream)
|
private void schematicServe(HttpServletRequest request, String requestedSchematic, OutputStream outputStream)
|
||||||
{
|
{
|
||||||
File worldeditFolder = HTTPDModule.getWorldeditFolder();
|
File worldeditFolder = HTTPDModule.getWorldeditFolder();
|
||||||
if (worldeditFolder == null)
|
if (worldeditFolder == null)
|
||||||
@@ -60,6 +62,7 @@ public class SchematicDownloadEndpoint extends AbstractServlet
|
|||||||
if (schemData != null)
|
if (schemData != null)
|
||||||
{
|
{
|
||||||
outputStream.write(schemData);
|
outputStream.write(schemData);
|
||||||
|
logDownload(request, schemFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (IOException ignored)
|
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()
|
private String schematicHTML()
|
||||||
{
|
{
|
||||||
String file = readFile(this.getClass().getResourceAsStream("/httpd/schematic_download.html"));
|
String file = readFile(this.getClass().getResourceAsStream("/httpd/schematic_download.html"));
|
||||||
|
|||||||
@@ -1,36 +1,20 @@
|
|||||||
package dev.plex.request.impl;
|
package dev.plex.request.impl;
|
||||||
|
|
||||||
import dev.plex.HTTPDModule;
|
import dev.plex.authentication.AuthenticatedUser;
|
||||||
import dev.plex.cache.DataUtils;
|
|
||||||
import dev.plex.player.PlexPlayer;
|
|
||||||
import dev.plex.request.AbstractServlet;
|
import dev.plex.request.AbstractServlet;
|
||||||
import dev.plex.request.GetMapping;
|
import dev.plex.request.GetMapping;
|
||||||
import dev.plex.util.PlexLog;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.bukkit.Bukkit;
|
|
||||||
import org.bukkit.OfflinePlayer;
|
|
||||||
|
|
||||||
public class SchematicUploadEndpoint extends AbstractServlet
|
public class SchematicUploadEndpoint extends AbstractServlet
|
||||||
{
|
{
|
||||||
@GetMapping(endpoint = "/api/schematics/upload/")
|
@GetMapping(endpoint = "/api/schematics/upload/")
|
||||||
public String uploadSchematic(HttpServletRequest request, HttpServletResponse response)
|
public String uploadSchematic(HttpServletRequest request, HttpServletResponse response)
|
||||||
{
|
{
|
||||||
String ipAddress = request.getRemoteAddr();
|
AuthenticatedUser user = currentStaff(request);
|
||||||
if (ipAddress == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
return schematicsHTML("An IP address could not be detected. Please ensure you are connecting using IPv4.");
|
return schematicsHTML(signInPrompt("to upload schematics"));
|
||||||
}
|
|
||||||
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 readFile(this.getClass().getResourceAsStream("/httpd/schematic_upload.html"));
|
return readFile(this.getClass().getResourceAsStream("/httpd/schematic_upload.html"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
server:
|
server:
|
||||||
bind-address: 0.0.0.0
|
bind-address: 0.0.0.0
|
||||||
port: 27192
|
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:
|
authentication:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
# Providers: discord
|
|
||||||
provider:
|
provider:
|
||||||
name: discord
|
# Absolute URL the XenForo authorize page will redirect back to.
|
||||||
redirectUri: ""
|
# Must match the redirect URI registered on the OAuth2 client in XenForo.
|
||||||
discord: # Fill if using discord provider
|
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: ""
|
clientId: ""
|
||||||
token: "" # Can also use environment variable or system property BOT_TOKEN
|
clientSecret: ""
|
||||||
|
# How long a successful login stays valid, in minutes.
|
||||||
|
sessionMinutes: 1440
|
||||||
|
|||||||
@@ -324,7 +324,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</nav>
|
</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">
|
<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 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>
|
<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 => {
|
document.querySelectorAll('.nav-link').forEach(a => {
|
||||||
if (a.classList.contains('active')) a.setAttribute('data-active', 'true');
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user