Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add guild configuration to web dashboard #35

Merged
merged 19 commits into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8fd73e3
Refactor caching and Discord OAuth setup
EpicOfficer Apr 16, 2024
a133c67
Add caching for user guilds and update JSON serialization
EpicOfficer Apr 16, 2024
88de899
Refactor caching mechanism in GenericRepository
EpicOfficer Apr 16, 2024
ebe79be
Update DiscordPartialGuild model and controllers
EpicOfficer Apr 16, 2024
5cb27e5
Integrate DiscordSocketClient into controllers
EpicOfficer Apr 17, 2024
86fb971
Add BlinkGuildsController and update BlinkGuildRepository
EpicOfficer Apr 17, 2024
478deb9
Implement BlinkGuild repository and update layout
EpicOfficer Apr 17, 2024
a6b1ae6
Add new settings pages and update layout design
EpicOfficer Apr 17, 2024
c99d82b
Update CSS properties and remove border from headings
EpicOfficer Apr 17, 2024
4ba0ba9
Add new guild-related endpoints in GuildsController
EpicOfficer Apr 17, 2024
cef3bd4
Add Wordle settings page with color customization options
EpicOfficer Apr 18, 2024
ca2bf6d
Update Wordle and TempVC pages
EpicOfficer Apr 18, 2024
604e1fa
Refactor TempVC and Wordle pages with service interfaces
EpicOfficer Apr 19, 2024
d0b98ac
Update Staff.razor and fix method names in DiscordGuildService
EpicOfficer Apr 19, 2024
bde5713
Add reset functionality and brand image CSS rules
EpicOfficer Apr 20, 2024
882c37a
Update favicon and add web app manifest
EpicOfficer Apr 20, 2024
62cbeca
Improve user feedback and add preload service in pages
EpicOfficer Apr 20, 2024
73c8431
Update confirmation dialog messages in TodoList and Wordle
EpicOfficer Apr 20, 2024
51f704c
Add NullableULongToStringConverter and adjust event handlers
EpicOfficer Apr 20, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Blink3.API/Blink3.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@

<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.Discord" Version="8.0.0"/>
<PackageReference Include="Discord.Addons.Hosting" Version="6.0.0" />
<PackageReference Include="Discord.Net" Version="3.14.1" />
<PackageReference Include="Discord.Net.Rest" Version="3.14.1"/>
<PackageReference Include="Microsoft.AspNetCore.JsonPatch" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4" />
<PackageReference Include="Serilog" Version="3.1.1"/>
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
Expand Down
71 changes: 70 additions & 1 deletion Blink3.API/Controllers/ApiControllerBase.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
using System.Net.Mime;
using Blink3.Core.Caching;
using Blink3.Core.DiscordAuth.Extensions;
using Blink3.Core.Models;
using Discord;
using Discord.Addons.Hosting.Util;
using Discord.Rest;
using Discord.WebSocket;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
Expand All @@ -18,8 +24,12 @@ namespace Blink3.API.Controllers;
[Route("api/[controller]")]
[ApiController]
[Authorize]
public abstract class ApiControllerBase : ControllerBase
public abstract class ApiControllerBase(DiscordSocketClient discordSocketClient, ICachingService cachingService) : ControllerBase
{
protected readonly ICachingService CachingService = cachingService;
protected DiscordRestClient? Client;
protected readonly DiscordSocketClient DiscordBotClient = discordSocketClient;

/// <summary>
/// Represents an Unauthorized Access message.
/// </summary>
Expand Down Expand Up @@ -79,4 +89,63 @@ private ObjectResult ProblemForUnauthorizedAccess()
if (userId is null) return ProblemForMissingItem();
return userId != UserId ? ProblemForUnauthorizedAccess() : null;
}

protected async Task InitDiscordClientAsync()
{
await DiscordBotClient.WaitForReadyAsync(CancellationToken.None);
if (Client is not null) return;
string? accessToken = await CachingService.GetAsync<string>($"token:{UserId}");
if (accessToken is null) return;

Client = new DiscordRestClient();
await Client.LoginAsync(TokenType.Bearer, accessToken);
}

protected async Task<List<DiscordPartialGuild>> GetUserGuilds()
{
await InitDiscordClientAsync();

List<DiscordPartialGuild> managedGuilds = await CachingService.GetOrAddAsync($"discord:guilds:{UserId}",
async () =>
{
List<DiscordPartialGuild> manageable = [];
if (Client is null) return manageable;

IAsyncEnumerable<IReadOnlyCollection<RestUserGuild>> guilds = Client.GetGuildSummariesAsync();
await foreach (IReadOnlyCollection<RestUserGuild> guildCollection in guilds)
{
manageable.AddRange(guildCollection.Where(g => g.Permissions.ManageGuild).Select(g =>
new DiscordPartialGuild
{
Id = g.Id,
Name = g.Name,
IconUrl = g.IconUrl
}));
}

return manageable;
}, TimeSpan.FromMinutes(5));

List<ulong> discordGuildIds = DiscordBotClient.Guilds.Select(b => b.Id).ToList();
return managedGuilds.Where(g => discordGuildIds.Contains(g.Id)).ToList();
}

/// <summary>
/// Checks if the user has access to the specified guild.
/// </summary>
/// <param name="guildId">The ID of the guild to check access for.</param>
/// <returns>
/// Returns an <see cref="ObjectResult"/> representing a problem response if the user doesn't have access, or null if the user has access.
/// </returns>
protected async Task<ObjectResult?> CheckGuildAccessAsync(ulong guildId)
{
List<DiscordPartialGuild> guilds = await GetUserGuilds();
return guilds.Any(g => g.Id == guildId) ? null : ProblemForUnauthorizedAccess();
}

~ApiControllerBase()
{
Client?.Dispose();
Client = null;
}
}
4 changes: 3 additions & 1 deletion Blink3.API/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using AspNet.Security.OAuth.Discord;
using Blink3.API.Interfaces;
using Blink3.API.Models;
using Blink3.Core.Caching;
using Blink3.Core.DiscordAuth;
using Blink3.Core.DiscordAuth.Extensions;
using Microsoft.AspNetCore.Authentication;
Expand All @@ -18,7 +19,8 @@
[Consumes(MediaTypeNames.Application.Json)]
public class AuthController(
IAuthenticationService authenticationService,
IDiscordTokenService discordTokenService) : ControllerBase
IDiscordTokenService discordTokenService,
ICachingService cachingService) : ControllerBase

Check warning on line 23 in Blink3.API/Controllers/AuthController.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'cachingService' is unread.

Check warning on line 23 in Blink3.API/Controllers/AuthController.cs

View workflow job for this annotation

GitHub Actions / build

Parameter 'cachingService' is unread.
{
[HttpGet("login")]
[SwaggerOperation(
Expand Down
125 changes: 125 additions & 0 deletions Blink3.API/Controllers/BlinkGuildsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using Blink3.Core.Caching;
using Blink3.Core.DTOs;
using Blink3.Core.Entities;
using Blink3.Core.Models;
using Blink3.Core.Repositories.Interfaces;
using Discord.WebSocket;
using Microsoft.AspNetCore.JsonPatch;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;

namespace Blink3.API.Controllers;

/// <summary>
/// Controller for performing CRUD operations on BlinkGuild items.
/// </summary>
[SwaggerTag("All CRUD operations for BlinkGuild items")]
public class BlinkGuildsController(DiscordSocketClient discordSocketClient, ICachingService cachingService, IBlinkGuildRepository blinkGuildRepository) : ApiControllerBase(discordSocketClient, cachingService)
{
/// <summary>
/// Retrieves all BlinkGuild items that are manageable by the logged in user.
/// </summary>
/// <returns>A list of BlinkGuild objects representing the guild configurations.</returns>
[HttpGet]
[SwaggerOperation(
Summary = "Returns all BlinkGuild items",
Description = "Returns a list of all of the BlinkGuilds that are manageable by the logged in user",
OperationId = "BlinkGuilds.GetAll",
Tags = ["BlinkGuilds"]
)]
[SwaggerResponse(StatusCodes.Status200OK, "Success", typeof(IEnumerable<BlinkGuild>))]
public async Task<ActionResult<IEnumerable<BlinkGuild>>> GetAllBlinkGuilds(CancellationToken cancellationToken)
{
List<DiscordPartialGuild> guilds = await GetUserGuilds();
IReadOnlyCollection<BlinkGuild> blinkGuilds = await blinkGuildRepository.FindByIdsAsync(guilds.Select(g => g.Id).ToHashSet());
return Ok(blinkGuilds);
}

/// <summary>
/// Retrieves a specific BlinkGuild item by its Id.
/// </summary>
/// <param name="id">The Id of the BlinkGuild item.</param>
/// <returns>The BlinkGuild item with the specified Id.</returns>
[HttpGet("{id}")]
[SwaggerOperation(
Summary = "Returns a specific BlinkGuild item",
Description = "Returns a BlinkGuild item by Id",
OperationId = "BlinkGuilds.GetBlinkGuild",
Tags = ["BlinkGuilds"]
)]
[SwaggerResponse(StatusCodes.Status200OK, "Success", typeof(BlinkGuild))]
public async Task<ActionResult<UserTodo>> GetBlinkGuild(ulong id)
{
ObjectResult? accessCheckResult = await CheckGuildAccessAsync(id);
if (accessCheckResult is not null) return accessCheckResult;

BlinkGuild blinkGuild = await blinkGuildRepository.GetOrCreateByIdAsync(id);
return Ok(blinkGuild);
}

/// <summary>
/// Updates the content of a specific BlinkGuild item.
/// </summary>
/// <param name="id">The ID of the BlinkGuild item to update.</param>
/// <param name="blinkGuild">The updated BlinkGuild item data.</param>
/// <returns>
/// No content.
/// </returns>
[HttpPut("{id}")]
[SwaggerOperation(
Summary = "Updates a specific BlinkGuild item",
Description = "Updates the content of a specific BlinkGuild item",
OperationId = "BlinkGuilds.Update",
Tags = ["BlinkGuilds"]
)]
[SwaggerResponse(StatusCodes.Status204NoContent, "No content")]
public async Task<ActionResult> UpdateBlinkGuild(ulong id, [FromBody] BlinkGuild blinkGuild,
CancellationToken cancellationToken)
{
ObjectResult? accessCheckResult = await CheckGuildAccessAsync(id);
if (accessCheckResult is not null) return accessCheckResult;

blinkGuild.Id = id;
await blinkGuildRepository.UpdateAsync(blinkGuild, cancellationToken);

return NoContent();
}

/// <summary>
/// Patches a specific BlinkGuild item.
/// Updates the content of a specific BlinkGuild item partially.
/// </summary>
/// <param name="id">The ID of the BlinkGuild item to patch.</param>
/// <param name="patchDoc">The <see cref="JsonPatchDocument{T}"/> containing the partial update.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Returns 204 (No content) if the patch is successful.</returns>
[HttpPatch("{id}")]
[SwaggerOperation(
Summary = "Patches a specific BlinkGuild item",
Description = "Updates the content of a specific BlinkGuild item partially",
OperationId = "BlinkGuilds.Patch",
Tags = ["BlinkGuilds"]
)]
[SwaggerResponse(StatusCodes.Status204NoContent, "No content")]
public async Task<IActionResult> PatchBlinkGuild(ulong id, [FromBody] JsonPatchDocument<BlinkGuild> patchDoc,
CancellationToken cancellationToken)
{
ObjectResult? accessCheckResult = await CheckGuildAccessAsync(id);
if (accessCheckResult is not null) return accessCheckResult;

BlinkGuild? blinkGuild = await blinkGuildRepository.GetByIdAsync(id);
if (blinkGuild is null)
{
return NotFound();
}
patchDoc.ApplyTo(blinkGuild);

if (!TryValidateModel(blinkGuild))
{
return BadRequest(ModelState);
}

await blinkGuildRepository.UpdateAsync(blinkGuild, cancellationToken);
return NoContent();
}
}
75 changes: 75 additions & 0 deletions Blink3.API/Controllers/GuildsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using Blink3.Core.Caching;
using Blink3.Core.Models;
using Discord.WebSocket;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;

namespace Blink3.API.Controllers;

[SwaggerTag("Endpoints for getting information on discord guilds")]
public class GuildsController(DiscordSocketClient discordSocketClient, ICachingService cachingService)
: ApiControllerBase(discordSocketClient, cachingService)
{
[HttpGet]
[SwaggerOperation(
Summary = "Returns all Discord guilds",
Description = "Returns a list of Discord guilds the currently logged in user has access to manage.",
OperationId = "Guilds.GetAll",
Tags = ["Guilds"]
)]
[SwaggerResponse(StatusCodes.Status200OK, "Success", typeof(DiscordPartialGuild[]))]
public async Task<ActionResult<DiscordPartialGuild[]>> GetAllGuilds()
{
List<DiscordPartialGuild> guilds = await GetUserGuilds();

return Ok(guilds);
}

[HttpGet("{id}/categories")]
[SwaggerOperation(
Summary = "Returns all categories for a guild",
Description = "Returns a list of all Discord category channels for a given guild ID",
OperationId = "Guilds.GetCategories",
Tags = ["Guilds"]
)]
[SwaggerResponse(StatusCodes.Status200OK, "Success", typeof(DiscordPartialChannel[]))]
public async Task<ActionResult<IReadOnlyCollection<DiscordPartialChannel>>> GetCategories(ulong id)
{
ObjectResult? accessCheckResult = await CheckGuildAccessAsync(id);
if (accessCheckResult is not null) return accessCheckResult;

return DiscordBotClient.GetGuild(id).CategoryChannels
.OrderBy(c => c.Position)
.Select(c =>
new DiscordPartialChannel
{
Id = c.Id,
Name = c.Name
})
.ToList();
}

[HttpGet("{id}/channels")]
[SwaggerOperation(
Summary = "Returns all chanels for a guild",
Description = "Returns a list of all Discord channels for a given guild ID",
OperationId = "Guilds.GetChannels",
Tags = ["Guilds"]
)]
[SwaggerResponse(StatusCodes.Status200OK, "Success", typeof(DiscordPartialChannel[]))]
public async Task<ActionResult<IReadOnlyCollection<DiscordPartialChannel>>> GetChannels(ulong id)
{
ObjectResult? accessCheckResult = await CheckGuildAccessAsync(id);
if (accessCheckResult is not null) return accessCheckResult;

return DiscordBotClient.GetGuild(id).TextChannels
.OrderBy(c => c.Position)
.Select(c =>
new DiscordPartialChannel
{
Id = c.Id,
Name = c.Name
})
.ToList();
}
}
4 changes: 3 additions & 1 deletion Blink3.API/Controllers/TodoController.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using Blink3.Core.Caching;
using Blink3.Core.DTOs;
using Blink3.Core.Entities;
using Blink3.Core.Repositories.Interfaces;
using Discord.WebSocket;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;

Expand All @@ -10,7 +12,7 @@ namespace Blink3.API.Controllers;
/// Controller for performing CRUD operations on userTodo items.
/// </summary>
[SwaggerTag("All CRUD operations for todo items")]
public class TodoController(IUserTodoRepository todoRepository) : ApiControllerBase
public class TodoController(DiscordSocketClient discordSocketClient, ICachingService cachingService, IUserTodoRepository todoRepository) : ApiControllerBase(discordSocketClient, cachingService)
{
/// <summary>
/// Retrieves all userTodo items for the current user.
Expand Down
Loading