Added Ellie.VotesApi

This commit is contained in:
Emotion 2023-07-11 22:00:14 +12:00
parent 6e293408ce
commit 92925d2d0e
No known key found for this signature in database
GPG key ID: D7D3E4C27A98C37B
22 changed files with 599 additions and 8 deletions

View file

@ -8,11 +8,12 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{0B2F1537-4BF0-422B-A0DD-8F9CCEFB340F}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{0B2F1537-4BF0-422B-A0DD-8F9CCEFB340F}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
CHANGELOG.md = CHANGELOG.md CHANGELOG.md = CHANGELOG.md
LICENSE.md = LICENSE.md
README.md = README.md
Dockerfile = Dockerfile Dockerfile = Dockerfile
LICENSE.md = LICENSE.md
NuGet.Config = NuGet.Config NuGet.Config = NuGet.Config
README.md = README.md
EndProjectSection EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie", "src\Ellie\Ellie.csproj", "{2BAF005E-781D-45FF-B218-E6361F5E8CD4}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie", "src\Ellie\Ellie.csproj", "{2BAF005E-781D-45FF-B218-E6361F5E8CD4}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ayu", "ayu", "{5284415D-A43F-4539-9483-410124199743}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ayu", "ayu", "{5284415D-A43F-4539-9483-410124199743}"
@ -25,7 +26,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Marmalade", "src\Elli
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Coordinator", "src\Ellie.Coordinator\Ellie.Coordinator.csproj", "{44BE7271-BABE-46BE-BB41-A5B6F1116C21}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Coordinator", "src\Ellie.Coordinator\Ellie.Coordinator.csproj", "{44BE7271-BABE-46BE-BB41-A5B6F1116C21}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.Bot.Generators.Strings", "src\Ellie.Bot.Generators.Strings\Ellie.Bot.Generators.Strings.csproj", "{11DE9EB6-2793-4540-BE66-701D2D02903A}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Bot.Generators.Strings", "src\Ellie.Bot.Generators.Strings\Ellie.Bot.Generators.Strings.csproj", "{11DE9EB6-2793-4540-BE66-701D2D02903A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ellie.VotesApi", "src\Ellie.VotesApi\Ellie.VotesApi.csproj", "{8D996036-52D1-4B11-B7D7-6F853A907EDD}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -57,6 +60,10 @@ Global
{11DE9EB6-2793-4540-BE66-701D2D02903A}.Debug|Any CPU.Build.0 = Debug|Any CPU {11DE9EB6-2793-4540-BE66-701D2D02903A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11DE9EB6-2793-4540-BE66-701D2D02903A}.Release|Any CPU.ActiveCfg = Release|Any CPU {11DE9EB6-2793-4540-BE66-701D2D02903A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11DE9EB6-2793-4540-BE66-701D2D02903A}.Release|Any CPU.Build.0 = Release|Any CPU {11DE9EB6-2793-4540-BE66-701D2D02903A}.Release|Any CPU.Build.0 = Release|Any CPU
{8D996036-52D1-4B11-B7D7-6F853A907EDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8D996036-52D1-4B11-B7D7-6F853A907EDD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8D996036-52D1-4B11-B7D7-6F853A907EDD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8D996036-52D1-4B11-B7D7-6F853A907EDD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -69,6 +76,7 @@ Global
{D6CF9ABE-205E-4699-90CA-0F18ED236490} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {D6CF9ABE-205E-4699-90CA-0F18ED236490} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{44BE7271-BABE-46BE-BB41-A5B6F1116C21} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {44BE7271-BABE-46BE-BB41-A5B6F1116C21} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{11DE9EB6-2793-4540-BE66-701D2D02903A} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67} {11DE9EB6-2793-4540-BE66-701D2D02903A} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
{8D996036-52D1-4B11-B7D7-6F853A907EDD} = {C5E3EF2E-72CF-41BB-B0C5-EB4C08403E67}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {878761F1-C7B5-4D38-A00D-3377D703EBBA} SolutionGuid = {878761F1-C7B5-4D38-A00D-3377D703EBBA}

View file

@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

1
src/Ellie.VotesApi/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
store/

View file

@ -0,0 +1,41 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Ellie.VotesApi
{
public class AuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "AUTHORIZATION_SCHEME";
public const string DiscordsClaim = "DISCORDS_CLAIM";
public const string TopggClaim = "TOPGG_CLAIM";
private readonly IConfiguration _conf;
public AuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
IConfiguration conf)
: base(options, logger, encoder, clock)
=> _conf = conf;
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim>();
if (_conf[ConfKeys.DISCORDS_KEY].Trim() == Request.Headers["Authorization"].ToString().Trim())
claims.Add(new(DiscordsClaim, "true"));
if (_conf[ConfKeys.TOPGG_KEY] == Request.Headers["Authorization"].ToString().Trim())
claims.Add(new Claim(TopggClaim, "true"));
return Task.FromResult(AuthenticateResult.Success(new(new(new ClaimsIdentity(claims)), SchemeName)));
}
}
}

View file

@ -0,0 +1,8 @@
namespace Ellie.VotesApi
{
public static class ConfKeys
{
public const string DISCORDS_KEY = "DiscordsKey";
public const string TOPGG_KEY = "TopGGKey";
}
}

View file

@ -0,0 +1,26 @@
namespace Ellie.VotesApi
{
public class DiscordsVoteWebhookModel
{
/// <summary>
/// The ID of the user who voted
/// </summary>
public string User { get; set; }
/// <summary>
/// The ID of the bot which recieved the vote
/// </summary>
public string Bot { get; set; }
/// <summary>
/// Contains totalVotes, votesMonth, votes24, hasVoted - a list of IDs of users who have voted this month, and
/// Voted24 - a list of IDs of users who have voted today
/// </summary>
public string Votes { get; set; }
/// <summary>
/// The type of event, whether it is a vote event or test event
/// </summary>
public string Type { get; set; }
}
}

View file

@ -0,0 +1,8 @@
namespace Ellie.VotesApi
{
public static class Policies
{
public const string DiscordsAuth = "DiscordsAuth";
public const string TopggAuth = "TopggAuth";
}
}

View file

@ -0,0 +1,30 @@
namespace Ellie.VotesApi
{
public class TopggVoteWebhookModel
{
/// <summary>
/// Discord ID of the bot that received a vote.
/// </summary>
public string Bot { get; set; }
/// <summary>
/// Discord ID of the user who voted.
/// </summary>
public string User { get; set; }
/// <summary>
/// The type of the vote (should always be "upvote" except when using the test button it's "test").
/// </summary>
public string Type { get; set; }
/// <summary>
/// Whether the weekend multiplier is in effect, meaning users votes count as two.
/// </summary>
public bool Weekend { get; set; }
/// <summary>
/// Query string params found on the /bot/:ID/vote page. Example: ?a=1&amp;b=2.
/// </summary>
public string Query { get; set; }
}
}

View file

@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Ellie.VotesApi.Services;
namespace Ellie.VotesApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class DiscordsController : ControllerBase
{
private readonly ILogger<DiscordsController> _logger;
private readonly IVotesCache _cache;
public DiscordsController(ILogger<DiscordsController> logger, IVotesCache cache)
{
_logger = logger;
_cache = cache;
}
[HttpGet("new")]
[Authorize(Policy = Policies.DiscordsAuth)]
public async Task<IEnumerable<Vote>> New()
{
var votes = await _cache.GetNewDiscordsVotesAsync();
if(votes.Count > 0)
_logger.LogInformation("Sending {NewDiscordsVotes} new discords votes", votes.Count);
return votes;
}
}
}

View file

@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Ellie.VotesApi.Services;
namespace Ellie.VotesApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class TopGgController : ControllerBase
{
private readonly ILogger<TopGgController> _logger;
private readonly IVotesCache _cache;
public TopGgController(ILogger<TopGgController> logger, IVotesCache cache)
{
_logger = logger;
_cache = cache;
}
[HttpGet("new")]
[Authorize(Policy = Policies.TopggAuth)]
public async Task<IEnumerable<Vote>> New()
{
var votes = await _cache.GetNewTopGgVotesAsync();
if (votes.Count > 0)
_logger.LogInformation("Sending {NewTopggVotes} new topgg votes", votes.Count);
return votes;
}
}
}

View file

@ -0,0 +1,48 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Ellie.VotesApi.Services;
namespace Ellie.VotesApi.Controllers
{
[ApiController]
public class WebhookController : ControllerBase
{
private readonly ILogger<WebhookController> _logger;
private readonly IVotesCache _votesCache;
public WebhookController(ILogger<WebhookController> logger, IVotesCache votesCache)
{
_logger = logger;
_votesCache = votesCache;
}
[HttpPost("/discordswebhook")]
[Authorize(Policy = Policies.DiscordsAuth)]
public async Task<IActionResult> DiscordsWebhook([FromBody] DiscordsVoteWebhookModel data)
{
_logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
data.User,
data.Bot,
"discords.com");
await _votesCache.AddNewDiscordsVote(data.User);
return Ok();
}
[HttpPost("/topggwebhook")]
[Authorize(Policy = Policies.TopggAuth)]
public async Task<IActionResult> TopggWebhook([FromBody] TopggVoteWebhookModel data)
{
_logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
data.User,
data.Bot,
"top.gg");
await _votesCache.AddNewTopggVote(data.User);
return Ok();
}
}
}

View file

@ -0,0 +1,20 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
WORKDIR /src
COPY ["src/Ellie.VotesApi/Ellie.VotesApi.csproj", "Ellie.VotesApi/"]
RUN dotnet restore "src/Ellie.VotesApi/Ellie.VotesApi.csproj"
COPY . .
WORKDIR "/src/Ellie.VotesApi"
RUN dotnet build "Ellie.VotesApi.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "Ellie.VotesApi.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Ellie.VotesApi.dll"]

View file

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MorseCode.ITask" Version="2.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Ellie.VotesApi;
CreateHostBuilder(args).Build().Run();
static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });

View file

@ -0,0 +1,31 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:16451",
"sslPort": 44323
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Ellie.VotesApi": {
"commandName": "Project",
"dotnetRunMessages": "true",
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,46 @@
## Votes Api
This api is used if you want your bot to be able to reward users who vote for it on discords.com or top.gg
#### [GET] `/discords/new`
Get the discords votes received after previous call to this endpoint.
Input full url of this endpoint in your creds.yml file under Discords url field.
For example "https://api.my.cool.bot/discords/new"
#### [GET] `/topgg/new`
Get the topgg votes received after previous call to this endpoint.
Input full url of this endpoint in your creds.yml file under Topgg url field.
For example "https://api.my.cool.bot/topgg/new"
#### [POST] `/discordswebhook`
Input this endpoint as the webhook on discords.com bot edit page
model: https://docs.botsfordiscord.com/methods/receiving-votes
For example "https://api.my.cool.bot/topggwebhook"
#### [POST] `/topggwebhook`
Input this endpoint as the webhook https://top.gg/bot/:your-bot-id/webhooks (replace :your-bot-id with your bot's id)
model: https://docs.top.gg/resources/webhooks/#schema
For example "https://api.my.cool.bot/discordswebhook"
Input your super-secret header value in appsettings.json's DiscordsKey and TopGGKey fields
They must match your DiscordsKey and TopGG key respectively, as well as your secrets in the discords.com and top.gg webhook setup pages
Full Example:
⚠ Change TopggKey and DiscordsKey to a secure long string
⚠ You can use https://www.random.org/strings/?num=1&len=20&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new to generate it
`creds.yml`
```yml
votes:
TopggServiceUrl: "https://api.my.cool.bot/topgg"
TopggKey: "my_topgg_key"
DiscordsServiceUrl: "https://api.my.cool.bot/discords"
DiscordsKey: "my_discords_key"
```
`appsettings.json`
```json
...
"DiscordsKey": "my_discords_key",
"TopGGKey": "my_topgg_key",
...
```

View file

@ -0,0 +1,100 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using MorseCode.ITask;
namespace Ellie.VotesApi.Services
{
public class FileVotesCache : IVotesCache
{
// private const string STATS_FILE = "store/stats.json";
private const string TOPGG_FILE = "store/topgg.json";
private const string DISCORDS_FILE = "store/discords.json";
private readonly SemaphoreSlim _locker = new SemaphoreSlim(1, 1);
public FileVotesCache()
{
if (!Directory.Exists("store"))
Directory.CreateDirectory("store");
if (!File.Exists(TOPGG_FILE))
File.WriteAllText(TOPGG_FILE, "[]");
if (!File.Exists(DISCORDS_FILE))
File.WriteAllText(DISCORDS_FILE, "[]");
}
public ITask AddNewTopggVote(string userId)
=> AddNewVote(TOPGG_FILE, userId);
public ITask AddNewDiscordsVote(string userId)
=> AddNewVote(DISCORDS_FILE, userId);
private async ITask AddNewVote(string file, string userId)
{
await _locker.WaitAsync();
try
{
var votes = await GetVotesAsync(file);
votes.Add(userId);
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(votes));
}
finally
{
_locker.Release();
}
}
public async ITask<IList<Vote>> GetNewTopGgVotesAsync()
{
var votes = await EvictTopggVotes();
return votes;
}
public async ITask<IList<Vote>> GetNewDiscordsVotesAsync()
{
var votes = await EvictDiscordsVotes();
return votes;
}
private ITask<List<Vote>> EvictTopggVotes()
=> EvictVotes(TOPGG_FILE);
private ITask<List<Vote>> EvictDiscordsVotes()
=> EvictVotes(DISCORDS_FILE);
private async ITask<List<Vote>> EvictVotes(string file)
{
await _locker.WaitAsync();
try
{
var ids = await GetVotesAsync(file);
await File.WriteAllTextAsync(file, "[]");
return ids?
.Select(x => (Ok: ulong.TryParse(x, out var r), Id: r))
.Where(x => x.Ok)
.Select(x => new Vote
{
UserId = x.Id
})
.ToList();
}
finally
{
_locker.Release();
}
}
private async ITask<IList<string>> GetVotesAsync(string file)
{
await using var fs = File.Open(file, FileMode.Open);
var votes = await JsonSerializer.DeserializeAsync<List<string>>(fs);
return votes;
}
}
}

View file

@ -0,0 +1,13 @@
using System.Collections.Generic;
using MorseCode.ITask;
namespace Ellie.VotesApi.Services
{
public interface IVotesCache
{
ITask<IList<Vote>> GetNewTopGgVotesAsync();
ITask<IList<Vote>> GetNewDiscordsVotesAsync();
ITask AddNewTopggVote(string userId);
ITask AddNewDiscordsVote(string userId);
}
}

View file

@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
using Ellie.VotesApi.Services;
namespace Ellie.VotesApi
{
public class Startup
{
public IConfiguration Configuration { get; }
public Startup(IConfiguration configuration)
=> Configuration = configuration;
// This method get called by the runtime. Use this method to add services tp the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSingleton<IVotesCache, FileVotesCache>();
services.AddSwaggerGen(static c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Ellie.VotesApi", Version = "v1" });
});
services
.AddAuthentication(opts =>
{
opts.DefaultScheme = AuthHandler.SchemeName;
opts.AddScheme<AuthHandler>(AuthHandler.SchemeName, AuthHandler.SchemeName);
});
services
.AddAuthorization(static opts =>
{
opts.DefaultPolicy = new AuthorizationPolicyBuilder(AuthHandler.SchemeName)
.RequireAssertion(static _ => false)
.Build();
opts.AddPolicy(Policies.DiscordsAuth, static policy => policy.RequireClaim(AuthHandler.DiscordsClaim));
opts.AddPolicy(Policies.TopggAuth, static policy => policy.RequireClaim(AuthHandler.TopggClaim));
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(static c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Ellie.VotesApi v1"));
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(static endpoints => { endpoints.MapControllers(); });
}
}
}

View file

@ -0,0 +1,7 @@
namespace Ellie.VotesApi
{
public class Vote
{
public ulong UserId { get; set; }
}
}

View file

@ -0,0 +1,10 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View file

@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"DiscordsKey": "my_discords_key",
"TopGGKey": "my_topgg_key",
"AllowedHosts": "*"
}