diff --git a/docs/web-api.md b/docs/web-api.md index 76518da..3f15c8f 100644 --- a/docs/web-api.md +++ b/docs/web-api.md @@ -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. | diff --git a/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java b/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java index 4f99f65..866fdf0 100644 --- a/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java +++ b/src/main/java/me/bobert/netneutrality/core/command/impl/MusicCommand.java @@ -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 diff --git a/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java b/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java index 7aecc1e..1ead1e4 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java +++ b/src/main/java/me/bobert/netneutrality/http/server/GuildRoutes.java @@ -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 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 jdaSupplier, Context ctx) { + private static void guilds(Supplier 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>>> 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.>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 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 userGuildEntry(Guild guild, Member member, GuildService guildService) { + boolean owner = guild.getOwnerIdLong() == member.getIdLong(); + Map 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 jdaSupplier, GuildServiceProvider guildServices, Context ctx) { + Guild guild = guild(jdaSupplier, ctx); + String userId = ctx.pathParam("userId"); + + Function> toBody = member -> { + boolean owner = guild.getOwnerIdLong() == member.getIdLong(); + Map 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 memberSummary(Member member) { Map 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 memberRolesSummary(Guild guild, Member member) { Map 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()); diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/AdminStatusResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/AdminStatusResponse.java index 8732812..4551b16 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/AdminStatusResponse.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/AdminStatusResponse.java @@ -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, diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/ChannelSummaryResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/ChannelSummaryResponse.java index 194c0b3..34e9735 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/ChannelSummaryResponse.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/ChannelSummaryResponse.java @@ -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( diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/GuildBooleanUpdateRequest.java b/src/main/java/me/bobert/netneutrality/http/server/dto/GuildBooleanUpdateRequest.java index 9de00f4..0671e4b 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/GuildBooleanUpdateRequest.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/GuildBooleanUpdateRequest.java @@ -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( diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/GuildConfigResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/GuildConfigResponse.java index 3843af0..047705e 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/GuildConfigResponse.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/GuildConfigResponse.java @@ -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( diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/GuildLogChannelUpdateRequest.java b/src/main/java/me/bobert/netneutrality/http/server/dto/GuildLogChannelUpdateRequest.java index 5e3774e..d9f40a9 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/GuildLogChannelUpdateRequest.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/GuildLogChannelUpdateRequest.java @@ -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 diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/MemberAccessResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/MemberAccessResponse.java new file mode 100644 index 0000000..8ae8367 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/MemberAccessResponse.java @@ -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 role_ids, + boolean is_owner, + boolean is_admin, + boolean can_use_music +) { +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/MemberRolesResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/MemberRolesResponse.java index a6f62a4..100fec8 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/MemberRolesResponse.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/MemberRolesResponse.java @@ -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, diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/MemberSummaryResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/MemberSummaryResponse.java index ba97b0e..fc03ed5 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/MemberSummaryResponse.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/MemberSummaryResponse.java @@ -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, diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/OwnerResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/OwnerResponse.java index 591f6ba..d6d9ff7 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/OwnerResponse.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/OwnerResponse.java @@ -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, diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/RoleSummaryResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/RoleSummaryResponse.java index 4f5ae0b..e64e432 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/RoleSummaryResponse.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/RoleSummaryResponse.java @@ -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( diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/UserGuildResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/UserGuildResponse.java new file mode 100644 index 0000000..fabb866 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/UserGuildResponse.java @@ -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 +) { +} diff --git a/src/main/java/me/bobert/netneutrality/http/server/dto/VoiceChannelSummaryResponse.java b/src/main/java/me/bobert/netneutrality/http/server/dto/VoiceChannelSummaryResponse.java index cf61973..ce54604 100644 --- a/src/main/java/me/bobert/netneutrality/http/server/dto/VoiceChannelSummaryResponse.java +++ b/src/main/java/me/bobert/netneutrality/http/server/dto/VoiceChannelSummaryResponse.java @@ -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, diff --git a/src/main/java/me/bobert/netneutrality/utils/MusicAccess.java b/src/main/java/me/bobert/netneutrality/utils/MusicAccess.java new file mode 100644 index 0000000..6794693 --- /dev/null +++ b/src/main/java/me/bobert/netneutrality/utils/MusicAccess.java @@ -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; + } +} diff --git a/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java b/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java index 8237cdd..f0fed31 100644 --- a/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java +++ b/src/test/java/me/bobert/netneutrality/http/server/WebApiTest.java @@ -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 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 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 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 response = get("/guilds?user_id=200"); + + assertTrue(response.body().contains("\"is_owner\":false,\"is_admin\":true")); + } + + @Test + void userGuildsFlagsOwner() throws Exception { + HttpResponse response = get("/guilds?user_id=42"); + + assertTrue(response.body().contains("\"is_owner\":true,\"is_admin\":true")); + } + + @Test + void userGuildsForUnknownUserIsEmpty() throws Exception { + HttpResponse 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 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 response = get("/guilds/channels/999"); + HttpResponse response = get("/guilds/999/channels"); assertEquals(404, response.statusCode()); assertTrue(response.body().contains("\"status\":\"error\"")); diff --git a/src/test/java/me/bobert/netneutrality/utils/MusicAccessTest.java b/src/test/java/me/bobert/netneutrality/utils/MusicAccessTest.java new file mode 100644 index 0000000..1eb8469 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/utils/MusicAccessTest.java @@ -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); + } +}