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/")
|
||||
}
|
||||
|
||||
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
-2
@@ -14,5 +14,4 @@ pluginManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Module-HTTPD"
|
||||
|
||||
rootProject.name = "Module-HTTPD"
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -4,23 +4,93 @@ import dev.plex.HTTPDModule;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.ChatColor;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public class Log
|
||||
{
|
||||
private static final DateTimeFormatter STAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS z");
|
||||
|
||||
private static BufferedWriter writer;
|
||||
private static File writerTarget;
|
||||
|
||||
public static void log(String message, Object... strings)
|
||||
{
|
||||
String formatted = format(message, strings);
|
||||
writeFile(formatted);
|
||||
if (HTTPDModule.moduleConfig != null && HTTPDModule.moduleConfig.getBoolean("server.logging.console", false))
|
||||
{
|
||||
Bukkit.getConsoleSender().sendMessage(Component.text("[Plex HTTPD] ").color(NamedTextColor.DARK_AQUA).append(Component.text(formatted).color(NamedTextColor.GRAY)));
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void shutdown()
|
||||
{
|
||||
if (writer != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
writer.flush();
|
||||
writer.close();
|
||||
}
|
||||
catch (IOException ignored) {}
|
||||
writer = null;
|
||||
writerTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String format(String message, Object... strings)
|
||||
{
|
||||
for (int i = 0; i < strings.length; i++)
|
||||
{
|
||||
if (message.contains("{" + i + "}"))
|
||||
String token = "{" + i + "}";
|
||||
if (message.contains(token))
|
||||
{
|
||||
message = message.replace("{" + i + "}", strings[i].toString());
|
||||
message = message.replace(token, strings[i] == null ? "null" : strings[i].toString());
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
if (HTTPDModule.moduleConfig.getBoolean("server.logging"))
|
||||
private static synchronized void writeFile(String formatted)
|
||||
{
|
||||
if (HTTPDModule.moduleConfig == null) return;
|
||||
if (!HTTPDModule.moduleConfig.getBoolean("server.logging.file", true)) return;
|
||||
File target = HTTPDModule.getAccessLogFile();
|
||||
if (target == null) return;
|
||||
if (writer == null || !target.equals(writerTarget))
|
||||
{
|
||||
Bukkit.getConsoleSender().sendMessage(Component.text("[Plex HTTPD] ").color(NamedTextColor.DARK_AQUA).append(Component.text(message).color(NamedTextColor.GRAY)));
|
||||
try
|
||||
{
|
||||
if (writer != null) writer.close();
|
||||
target.getParentFile().mkdirs();
|
||||
writer = new BufferedWriter(new FileWriter(target, true));
|
||||
writerTarget = target;
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
Bukkit.getLogger().warning("[Plex HTTPD] Failed to open access log " + target + ": " + e.getMessage());
|
||||
writer = null;
|
||||
writerTarget = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
writer.write(STAMP.format(ZonedDateTime.now()));
|
||||
writer.write(' ');
|
||||
writer.write(formatted);
|
||||
writer.newLine();
|
||||
writer.flush();
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
Bukkit.getLogger().warning("[Plex HTTPD] Failed to write access log: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '&').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>
|
||||
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user