Standardize guild API endpoints and add music access checks
Unified paths in guild-related endpoints to include consistent `{guildId}` structure, improving endpoint clarity and alignment with REST routing conventions. Added centralized `MusicAccess` utility to streamline music feature permission checks, replacing scattered logic in `MusicCommand`. Updated OpenAPI documentation and added tests for new and updated behaviors.
This commit is contained in:
1 parent
bedc8b9f02
commit
91b6de708d
18 files changed
+431
-66
No files matched your search
+1
-1
@@ -32,7 +32,7 @@ a path prefix.
|
||||
| Handler | Base path | Endpoints |
|
||||
|---|---|---|
|
||||
| `RootHandler` | `/` | Simple HTML "OK" landing response. |
|
||||
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/channels/{guildId}` — text channels (JSON); `/guilds/roles/{guildId}` — roles (JSON). |
|
||||
| `GuildHandler` | `/guilds` | `/guilds/owner` — guild ids + owner ids (JSON); `/guilds/{guildId}/channels` — text channels (JSON); `/guilds/{guildId}/roles` — roles (JSON). |
|
||||
| `ActionHandler` | `/actions` | `/actions/status` — JDA health + gateway ping; `/actions/shutdown` — graceful shutdown. |
|
||||
| `SettingsHandler` | `/settings` | `/settings/catalog` — every known setting with its default value and description; `/settings/current/{guildId}` — current guild settings; `/settings/reload/{guildId}` — invalidate & reload the settings cache. |
|
||||
|
||||
|
||||
@@ -9,10 +9,9 @@ import me.bobert.netneutrality.core.command.CommandCategory;
|
||||
import me.bobert.netneutrality.db.service.GuildServiceProvider;
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
import me.bobert.netneutrality.db.service.GuildService;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import me.bobert.netneutrality.utils.MusicAccess;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
||||
@@ -77,26 +76,7 @@ public abstract class MusicCommand extends AbstractCommand {
|
||||
return true;
|
||||
}
|
||||
|
||||
Guild guild = member.getGuild();
|
||||
|
||||
String roleDb = guildService(guild.getId()).getGuildSettingService().getOrDefault(GSettings.MUSIC_ROLE);
|
||||
|
||||
if (roleDb != null && !roleDb.isEmpty()) {
|
||||
if (UserFunction.isAdmin(member)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Role neededRole = guild.getRoleById(roleDb);
|
||||
if (neededRole == null) {
|
||||
// Role doesn't exist on guild anymore, remove from db
|
||||
guildService(guild.getId()).getGuildSettingService().set(GSettings.MUSIC_ROLE, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return member.getRoles().contains(neededRole);
|
||||
}
|
||||
|
||||
return true;
|
||||
return MusicAccess.canUse(member, guildService(member.getGuild()));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -27,11 +27,13 @@ import me.bobert.netneutrality.http.server.dto.GuildConfigResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.GuildLogChannelUpdateRequest;
|
||||
import me.bobert.netneutrality.http.server.dto.GuildOwnerEntryResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.GuildSummaryResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.MemberAccessResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.MemberRolesResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.MemberSummaryResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.OwnerResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.RoleSummaryResponse;
|
||||
import me.bobert.netneutrality.http.server.dto.VoiceChannelSummaryResponse;
|
||||
import me.bobert.netneutrality.utils.MusicAccess;
|
||||
import me.bobert.netneutrality.utils.UserFunction;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
@@ -42,6 +44,9 @@ import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
@@ -54,30 +59,41 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices) {
|
||||
routes.get("/guilds", ctx -> guilds(jdaSupplier, ctx));
|
||||
routes.get("/guilds", ctx -> guilds(jdaSupplier, guildServices, ctx));
|
||||
routes.get("/guilds/owner", ctx -> owner(jdaSupplier, ctx));
|
||||
routes.get("/guilds/owner/{guildId}", ctx -> ownerOf(jdaSupplier, ctx));
|
||||
routes.get("/guilds/channels/{guildId}", ctx -> channels(jdaSupplier, ctx));
|
||||
routes.get("/guilds/roles/{guildId}", ctx -> roles(jdaSupplier, ctx));
|
||||
routes.get("/guilds/members/{guildId}/{userId}", ctx -> member(jdaSupplier, ctx));
|
||||
routes.get("/guilds/members/{guildId}/{userId}/roles", ctx -> memberRoles(jdaSupplier, ctx));
|
||||
routes.get("/guilds/members/{guildId}/{userId}/admin", ctx -> memberAdmin(jdaSupplier, ctx));
|
||||
routes.get("/guilds/voice-channels/{guildId}", ctx -> voiceChannels(jdaSupplier, ctx));
|
||||
routes.get("/guilds/voice-channels/{guildId}/{channelId}", ctx -> voiceChannel(jdaSupplier, ctx));
|
||||
routes.get("/guilds/config/{guildId}", ctx -> config(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/guilds/debug/{guildId}", ctx -> updateDebug(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/guilds/logging/{guildId}", ctx -> updateLogging(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/guilds/log-channel/{guildId}", ctx -> updateLogChannel(jdaSupplier, guildServices, ctx));
|
||||
routes.get("/guilds/{guildId}/owner", ctx -> ownerOf(jdaSupplier, ctx));
|
||||
routes.get("/guilds/{guildId}/channels", ctx -> channels(jdaSupplier, ctx));
|
||||
routes.get("/guilds/{guildId}/roles", ctx -> roles(jdaSupplier, ctx));
|
||||
routes.get("/guilds/{guildId}/members/{userId}/access", ctx -> memberAccess(jdaSupplier, guildServices, ctx));
|
||||
routes.get("/guilds/{guildId}/members/{userId}", ctx -> member(jdaSupplier, ctx));
|
||||
routes.get("/guilds/{guildId}/members/{userId}/roles", ctx -> memberRoles(jdaSupplier, ctx));
|
||||
routes.get("/guilds/{guildId}/members/{userId}/admin", ctx -> memberAdmin(jdaSupplier, ctx));
|
||||
routes.get("/guilds/{guildId}/voice-channels", ctx -> voiceChannels(jdaSupplier, ctx));
|
||||
routes.get("/guilds/{guildId}/voice-channels/{channelId}", ctx -> voiceChannel(jdaSupplier, ctx));
|
||||
routes.get("/guilds/{guildId}/config", ctx -> config(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/guilds/{guildId}/debug", ctx -> updateDebug(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/guilds/{guildId}/logging", ctx -> updateLogging(jdaSupplier, guildServices, ctx));
|
||||
routes.put("/guilds/{guildId}/log-channel", ctx -> updateLogChannel(jdaSupplier, guildServices, ctx));
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "List every guild the bot is currently in",
|
||||
summary = "List every guild the bot is in, or only the guilds shared with a user when user_id is given",
|
||||
tags = {"Guilds"},
|
||||
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildSummaryResponse[].class))
|
||||
queryParams = @OpenApiParam(name = "user_id", description = "Optional Discord user id; restricts the list to mutual guilds and switches to the user-guild shape"),
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildSummaryResponse[].class)),
|
||||
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void guilds(Supplier<JDA> jdaSupplier, Context ctx) {
|
||||
private static void guilds(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
String userId = ctx.queryParam("user_id");
|
||||
if (userId != null) {
|
||||
userGuilds(jda(jdaSupplier), guildServices, userId, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.json(
|
||||
jda(jdaSupplier).getGuilds().stream()
|
||||
.map(GuildRoutes::guildSummary)
|
||||
@@ -85,6 +101,49 @@ public final class GuildRoutes {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the guilds shared by the bot and the given user, with the user's access flags. Members
|
||||
* missing from the cache are resolved through REST; a failed lookup means "not a member".
|
||||
*/
|
||||
private static void userGuilds(JDA jda, GuildServiceProvider guildServices, String userId, Context ctx) {
|
||||
if (!userId.matches("\\d{1,20}")) {
|
||||
throw new BadRequestResponse("user_id must be a numeric Discord id");
|
||||
}
|
||||
|
||||
List<CompletableFuture<Optional<Map<String, Object>>>> lookups = jda.getGuilds().stream()
|
||||
.map(guild -> resolveMember(guild, userId)
|
||||
.thenApply(member -> Optional.ofNullable(member)
|
||||
.map(m -> userGuildEntry(guild, m, guildServices.get(guild.getId()))))
|
||||
.exceptionally(error -> Optional.<Map<String, Object>>empty()))
|
||||
.toList();
|
||||
|
||||
ctx.future(() -> CompletableFuture.allOf(lookups.toArray(new CompletableFuture[0])).thenApply(ignored -> {
|
||||
ctx.json(lookups.stream().map(CompletableFuture::join).flatMap(Optional::stream).toList());
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
private static CompletableFuture<Member> resolveMember(Guild guild, String userId) {
|
||||
Member cached = guild.getMemberById(userId);
|
||||
if (cached != null) {
|
||||
return CompletableFuture.completedFuture(cached);
|
||||
}
|
||||
|
||||
return guild.retrieveMemberById(userId).submit();
|
||||
}
|
||||
|
||||
private static Map<String, Object> userGuildEntry(Guild guild, Member member, GuildService guildService) {
|
||||
boolean owner = guild.getOwnerIdLong() == member.getIdLong();
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("id", guild.getId());
|
||||
entry.put("name", guild.getName());
|
||||
entry.put("icon", guild.getIconUrl());
|
||||
entry.put("is_owner", owner);
|
||||
entry.put("is_admin", owner || UserFunction.isAdmin(member));
|
||||
entry.put("can_use_music", MusicAccess.canUse(member, guildService));
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@code /guilds} entry for a guild, falling back to a REST fetch for the owner id
|
||||
* when it is not present in the cache.
|
||||
@@ -126,7 +185,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/owner/{guildId}",
|
||||
path = "/guilds/{guildId}/owner",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get a guild's owner, falling back to a REST fetch on a member cache miss",
|
||||
tags = {"Guilds"},
|
||||
@@ -148,7 +207,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/channels/{guildId}",
|
||||
path = "/guilds/{guildId}/channels",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "List the text channels of a guild",
|
||||
tags = {"Guilds"},
|
||||
@@ -167,7 +226,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/roles/{guildId}",
|
||||
path = "/guilds/{guildId}/roles",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "List the roles of a guild",
|
||||
tags = {"Guilds"},
|
||||
@@ -187,7 +246,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/members/{guildId}/{userId}",
|
||||
path = "/guilds/{guildId}/members/{userId}",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get a guild member, falling back to a REST fetch on a member cache miss",
|
||||
tags = {"Guilds"},
|
||||
@@ -213,7 +272,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/members/{guildId}/{userId}/roles",
|
||||
path = "/guilds/{guildId}/members/{userId}/roles",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "List the roles of a guild member, falling back to a REST fetch on a member cache miss",
|
||||
tags = {"Guilds"},
|
||||
@@ -239,7 +298,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/members/{guildId}/{userId}/admin",
|
||||
path = "/guilds/{guildId}/members/{userId}/admin",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Check whether a guild member is an admin of the guild, falling back to a REST fetch on a member cache miss",
|
||||
tags = {"Guilds"},
|
||||
@@ -270,8 +329,54 @@ public final class GuildRoutes {
|
||||
);
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/{guildId}/members/{userId}/access",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get a member's roles, admin/owner flags, music access and presence in one call",
|
||||
tags = {"Guilds"},
|
||||
pathParams = {
|
||||
@io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
|
||||
@io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id")
|
||||
},
|
||||
responses = {
|
||||
@OpenApiResponse(status = "200", content = @OpenApiContent(from = MemberAccessResponse.class)),
|
||||
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
|
||||
}
|
||||
)
|
||||
private static void memberAccess(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
|
||||
Guild guild = guild(jdaSupplier, ctx);
|
||||
String userId = ctx.pathParam("userId");
|
||||
|
||||
Function<Member, Map<String, Object>> toBody = member -> {
|
||||
boolean owner = guild.getOwnerIdLong() == member.getIdLong();
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("guild_id", guild.getId());
|
||||
summary.put("user_id", member.getId());
|
||||
summary.put("role_ids", member.getRoles().stream().map(Role::getId).toList());
|
||||
summary.put("is_owner", owner);
|
||||
summary.put("is_admin", owner || UserFunction.isAdmin(member));
|
||||
summary.put("can_use_music", MusicAccess.canUse(member, guildServices.get(guild.getId())));
|
||||
return summary;
|
||||
};
|
||||
|
||||
Member cached = guild.getMemberById(userId);
|
||||
if (cached != null) {
|
||||
ctx.json(toBody.apply(cached));
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.future(() -> guild.retrieveMemberById(userId).submit().handle((member, error) -> {
|
||||
if (error != null || member == null) {
|
||||
ctx.status(404).json(Map.of("status", "error", "message", "Member not found in guild"));
|
||||
} else {
|
||||
ctx.json(toBody.apply(member));
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@code /guilds/members/{guildId}/{userId}} response for a resolved member.
|
||||
* Builds the {@code /guilds/{guildId}/members/{userId}} response for a resolved member.
|
||||
*/
|
||||
private static Map<String, Object> memberSummary(Member member) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
@@ -283,7 +388,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@code /guilds/members/{guildId}/{userId}/roles} response for a resolved member.
|
||||
* Builds the {@code /guilds/{guildId}/members/{userId}/roles} response for a resolved member.
|
||||
*/
|
||||
private static Map<String, Object> memberRolesSummary(Guild guild, Member member) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
@@ -298,7 +403,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/voice-channels/{guildId}",
|
||||
path = "/guilds/{guildId}/voice-channels",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "List the voice channels of a guild, including the members currently connected",
|
||||
tags = {"Guilds"},
|
||||
@@ -317,7 +422,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/voice-channels/{guildId}/{channelId}",
|
||||
path = "/guilds/{guildId}/voice-channels/{channelId}",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get a voice channel, including the members currently connected",
|
||||
tags = {"Guilds"},
|
||||
@@ -382,7 +487,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/config/{guildId}",
|
||||
path = "/guilds/{guildId}/config",
|
||||
methods = HttpMethod.GET,
|
||||
summary = "Get a guild's debug/logging configuration",
|
||||
tags = {"Guilds"},
|
||||
@@ -398,7 +503,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/debug/{guildId}",
|
||||
path = "/guilds/{guildId}/debug",
|
||||
methods = HttpMethod.PUT,
|
||||
summary = "Enable or disable debug/error logging for a guild",
|
||||
tags = {"Guilds"},
|
||||
@@ -421,7 +526,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/logging/{guildId}",
|
||||
path = "/guilds/{guildId}/logging",
|
||||
methods = HttpMethod.PUT,
|
||||
summary = "Enable or disable logging for a guild",
|
||||
tags = {"Guilds"},
|
||||
@@ -444,7 +549,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
@OpenApi(
|
||||
path = "/guilds/log-channel/{guildId}",
|
||||
path = "/guilds/{guildId}/log-channel",
|
||||
methods = HttpMethod.PUT,
|
||||
summary = "Set (or clear) the log channel for a guild",
|
||||
tags = {"Guilds"},
|
||||
@@ -482,7 +587,7 @@ public final class GuildRoutes {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the {@code /guilds/config/{guildId}} response for a guild's current configuration.
|
||||
* Builds the {@code /guilds/{guildId}/config} response for a guild's current configuration.
|
||||
*/
|
||||
private static GuildConfigResponse guildConfig(Guild guild, GuildServiceProvider guildServices) {
|
||||
GuildService guildService = guildServices.get(guild.getId());
|
||||
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}/admin} response.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/members/{userId}/admin} response.
|
||||
*/
|
||||
public record AdminStatusResponse(
|
||||
String guildId,
|
||||
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for a {@code /guilds/channels/{guildId}} and {@code /guilds/roles/{guildId}}
|
||||
* OpenAPI schema for a {@code /guilds/{guildId}/channels} and {@code /guilds/{guildId}/roles}
|
||||
* entry.
|
||||
*/
|
||||
public record ChannelSummaryResponse(
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/debug/{guildId}} and {@code /guilds/logging/{guildId}}
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/debug} and {@code /guilds/{guildId}/logging}
|
||||
* request bodies.
|
||||
*/
|
||||
public record GuildBooleanUpdateRequest(
|
||||
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/config/{guildId}} response, as well as the
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/config} response, as well as the
|
||||
* debug/logging/log-channel update responses.
|
||||
*/
|
||||
public record GuildConfigResponse(
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/log-channel/{guildId}} request body.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/log-channel} request body.
|
||||
*/
|
||||
public record GuildLogChannelUpdateRequest(
|
||||
String value
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package me.bobert.netneutrality.http.server.dto;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* OpenAPI schema for {@code /guilds/{guildId}/members/{userId}/access}.
|
||||
*/
|
||||
public record MemberAccessResponse(
|
||||
String guild_id,
|
||||
String user_id,
|
||||
List<String> role_ids,
|
||||
boolean is_owner,
|
||||
boolean is_admin,
|
||||
boolean can_use_music
|
||||
) {
|
||||
}
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}/roles} response.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/members/{userId}/roles} response.
|
||||
*/
|
||||
public record MemberRolesResponse(
|
||||
String guild_id,
|
||||
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}} response.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/members/{userId}} response.
|
||||
*/
|
||||
public record MemberSummaryResponse(
|
||||
String user_id,
|
||||
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/owner/{guildId}} response.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/owner} response.
|
||||
*/
|
||||
public record OwnerResponse(
|
||||
String guild_id,
|
||||
|
||||
@@ -7,7 +7,7 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for a role entry of the {@code /guilds/members/{guildId}/{userId}/roles}
|
||||
* OpenAPI schema for a role entry of the {@code /guilds/{guildId}/members/{userId}/roles}
|
||||
* response.
|
||||
*/
|
||||
public record RoleSummaryResponse(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package me.bobert.netneutrality.http.server.dto;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for a {@code /guilds?user_id=} entry.
|
||||
*/
|
||||
public record UserGuildResponse(
|
||||
String id,
|
||||
String name,
|
||||
String icon,
|
||||
boolean is_owner,
|
||||
boolean is_admin,
|
||||
boolean can_use_music
|
||||
) {
|
||||
}
|
||||
+2
-2
@@ -7,8 +7,8 @@ package me.bobert.netneutrality.http.server.dto;
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAPI schema for the {@code /guilds/voice-channels/{guildId}} and
|
||||
* {@code /guilds/voice-channels/{guildId}/{channelId}} responses.
|
||||
* OpenAPI schema for the {@code /guilds/{guildId}/voice-channels} and
|
||||
* {@code /guilds/{guildId}/voice-channels/{channelId}} responses.
|
||||
*/
|
||||
public record VoiceChannelSummaryResponse(
|
||||
String channel_id,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package me.bobert.netneutrality.utils;
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
import me.bobert.netneutrality.db.service.GuildService;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
|
||||
/**
|
||||
* Single source of truth for the music-access rule.
|
||||
*/
|
||||
public final class MusicAccess {
|
||||
|
||||
private MusicAccess() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the member may use music features in their guild.
|
||||
*
|
||||
* @param member the member, {@code null} is allowed
|
||||
* @param guildService the guild service of the member's guild
|
||||
* @return true when access is allowed
|
||||
*/
|
||||
public static boolean canUse(Member member, GuildService guildService) {
|
||||
if (member == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Guild guild = member.getGuild();
|
||||
|
||||
String roleDb = guildService.getGuildSettingService().getOrDefault(GSettings.MUSIC_ROLE);
|
||||
|
||||
if (roleDb != null && !roleDb.isEmpty()) {
|
||||
if (UserFunction.isAdmin(member)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Role neededRole = guild.getRoleById(roleDb);
|
||||
if (neededRole == null) {
|
||||
// Role doesn't exist on guild anymore, remove from db
|
||||
guildService.getGuildSettingService().set(GSettings.MUSIC_ROLE, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return member.getRoles().contains(neededRole);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,12 @@ import me.bobert.netneutrality.db.service.GuildSettingService;
|
||||
import me.bobert.netneutrality.music.MusicService;
|
||||
import me.bobert.netneutrality.utils.SettingsProvider;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.OnlineStatus;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
import net.dv8tion.jda.api.requests.restaction.CacheRestAction;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -24,8 +29,10 @@ import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -56,6 +63,22 @@ class WebApiTest {
|
||||
when(guild.getMemberCount()).thenReturn(7);
|
||||
when(guild.getChannels()).thenReturn(List.of());
|
||||
when(guild.getRoles()).thenReturn(List.of());
|
||||
when(guild.getOwnerIdLong()).thenReturn(42L);
|
||||
|
||||
Member plain = mockMember(guild, 100L, List.of());
|
||||
Role manageServer = mock(Role.class);
|
||||
when(manageServer.getPermissions()).thenReturn(EnumSet.of(Permission.MANAGE_SERVER));
|
||||
Member admin = mockMember(guild, 200L, List.of(manageServer));
|
||||
Member owner = mockMember(guild, 42L, List.of());
|
||||
when(guild.getMemberById(any(String.class))).thenAnswer(inv -> switch ((String) inv.getArgument(0)) {
|
||||
case "100" -> plain;
|
||||
case "200" -> admin;
|
||||
case "42" -> owner;
|
||||
default -> null;
|
||||
});
|
||||
CacheRestAction<Member> failing = mock(CacheRestAction.class);
|
||||
when(failing.submit()).thenReturn(CompletableFuture.failedFuture(new IllegalStateException("unknown member")));
|
||||
when(guild.retrieveMemberById(any(String.class))).thenReturn(failing);
|
||||
|
||||
JDA jda = mock(JDA.class);
|
||||
when(jda.getGuilds()).thenReturn(List.of(guild));
|
||||
@@ -157,6 +180,72 @@ class WebApiTest {
|
||||
assertTrue(response.body().contains("\"owner_id\":\"42\""));
|
||||
}
|
||||
|
||||
private static Member mockMember(Guild guild, long id, List<Role> roles) {
|
||||
Member member = mock(Member.class);
|
||||
when(member.getId()).thenReturn(String.valueOf(id));
|
||||
when(member.getIdLong()).thenReturn(id);
|
||||
when(member.getGuild()).thenReturn(guild);
|
||||
when(member.getRoles()).thenReturn(roles);
|
||||
when(member.getOnlineStatus()).thenReturn(OnlineStatus.ONLINE);
|
||||
return member;
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsReturnsAccessFlagsForPlainMember() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds?user_id=100");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("[{\"id\":\"" + GUILD_ID + "\",\"name\":\"My Guild\",\"icon\":\"https://example.com/icon.png\","
|
||||
+ "\"is_owner\":false,\"is_admin\":false,\"can_use_music\":true}]", response.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsFlagsManageServerAsAdmin() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds?user_id=200");
|
||||
|
||||
assertTrue(response.body().contains("\"is_owner\":false,\"is_admin\":true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsFlagsOwner() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds?user_id=42");
|
||||
|
||||
assertTrue(response.body().contains("\"is_owner\":true,\"is_admin\":true"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsForUnknownUserIsEmpty() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds?user_id=999");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("[]", response.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsWithMalformedUserIdIsRejected() throws Exception {
|
||||
assertEquals(400, get("/guilds?user_id=abc").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberAccessReturnsRolesFlagsAndPresence() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds/" + GUILD_ID + "/members/200/access");
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"user_id\":\"200\""));
|
||||
assertTrue(response.body().contains("\"is_owner\":false,\"is_admin\":true,\"can_use_music\":true"));
|
||||
assertTrue(response.body().contains("\"presence\":\"ONLINE\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberAccessForUserNotInGuildIsNotFound() throws Exception {
|
||||
assertEquals(404, get("/guilds/" + GUILD_ID + "/members/999/access").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userGuildsRequiresApiKey() throws Exception {
|
||||
assertEquals(401, getWithoutKey("/guilds?user_id=100").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void guildsRequiresApiKey() throws Exception {
|
||||
assertEquals(401, getWithoutKey("/guilds").statusCode());
|
||||
@@ -206,7 +295,7 @@ class WebApiTest {
|
||||
|
||||
@Test
|
||||
void unknownGuildReturnsNotFound() throws Exception {
|
||||
HttpResponse<String> response = get("/guilds/channels/999");
|
||||
HttpResponse<String> response = get("/guilds/999/channels");
|
||||
|
||||
assertEquals(404, response.statusCode());
|
||||
assertTrue(response.body().contains("\"status\":\"error\""));
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package me.bobert.netneutrality.utils;
|
||||
|
||||
/*
|
||||
* Copyright (c) anthorob
|
||||
*
|
||||
* https://discord.gg/qUa6zNFZsd
|
||||
*/
|
||||
|
||||
import me.bobert.netneutrality.db.GSettings;
|
||||
import me.bobert.netneutrality.db.service.GuildService;
|
||||
import me.bobert.netneutrality.db.service.GuildSettingService;
|
||||
import net.dv8tion.jda.api.Permission;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Role;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MusicAccessTest {
|
||||
|
||||
private GuildSettingService settings;
|
||||
private GuildService service;
|
||||
private Guild guild;
|
||||
private Member member;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
UserFunction.bind(null);
|
||||
settings = mock(GuildSettingService.class);
|
||||
service = mock(GuildService.class);
|
||||
when(service.getGuildSettingService()).thenReturn(settings);
|
||||
guild = mock(Guild.class);
|
||||
when(guild.getId()).thenReturn("g1");
|
||||
member = mock(Member.class);
|
||||
when(member.getGuild()).thenReturn(guild);
|
||||
when(member.getRoles()).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullMemberAndUnsetRoleAllowEveryone() {
|
||||
assertTrue(MusicAccess.canUse(null, service));
|
||||
when(settings.getOrDefault(GSettings.MUSIC_ROLE)).thenReturn("");
|
||||
assertTrue(MusicAccess.canUse(member, service));
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberWithoutRoleIsDenied() {
|
||||
Role needed = mock(Role.class);
|
||||
when(settings.getOrDefault(GSettings.MUSIC_ROLE)).thenReturn("r1");
|
||||
when(guild.getRoleById("r1")).thenReturn(needed);
|
||||
|
||||
assertFalse(MusicAccess.canUse(member, service));
|
||||
}
|
||||
|
||||
@Test
|
||||
void memberWithRoleIsAllowed() {
|
||||
Role needed = mock(Role.class);
|
||||
when(needed.getPermissions()).thenReturn(EnumSet.noneOf(Permission.class));
|
||||
when(settings.getOrDefault(GSettings.MUSIC_ROLE)).thenReturn("r1");
|
||||
when(guild.getRoleById("r1")).thenReturn(needed);
|
||||
when(member.getRoles()).thenReturn(List.of(needed));
|
||||
|
||||
assertTrue(MusicAccess.canUse(member, service));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminIsAllowed() {
|
||||
Role adminRole = mock(Role.class);
|
||||
when(adminRole.getPermissions()).thenReturn(EnumSet.of(Permission.MANAGE_SERVER));
|
||||
when(member.getRoles()).thenReturn(List.of(adminRole));
|
||||
when(settings.getOrDefault(GSettings.MUSIC_ROLE)).thenReturn("r1");
|
||||
when(guild.getRoleById("r1")).thenReturn(mock(Role.class));
|
||||
|
||||
assertTrue(MusicAccess.canUse(member, service));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRoleAllowsAndClearsSetting() {
|
||||
when(settings.getOrDefault(GSettings.MUSIC_ROLE)).thenReturn("gone");
|
||||
when(guild.getRoleById("gone")).thenReturn(null);
|
||||
|
||||
assertTrue(MusicAccess.canUse(member, service));
|
||||
verify(settings).set(GSettings.MUSIC_ROLE, null);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user