Skip to content

Commit

Permalink
Merge pull request #37 from rubberduck-vba/webhook
Browse files Browse the repository at this point in the history
Add webhook controller
  • Loading branch information
retailcoder authored Feb 4, 2025
2 parents c3369ee + 7e2269b commit a0bdd41
Show file tree
Hide file tree
Showing 12 changed files with 335 additions and 70 deletions.
35 changes: 12 additions & 23 deletions rubberduckvba.Server/Api/Admin/AdminController.cs
Original file line number Diff line number Diff line change
@@ -1,53 +1,42 @@
using Hangfire;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using rubberduckvba.Server;
using rubberduckvba.Server.ContentSynchronization;
using rubberduckvba.Server.Hangfire;
using rubberduckvba.Server.Services;

namespace rubberduckvba.Server.Api.Admin;


[ApiController]
public class AdminController(ConfigurationOptions options, IBackgroundJobClient backgroundJob, ILogger<AdminController> logger) : ControllerBase
public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire) : ControllerBase
{
/// <summary>
/// Enqueues a job that updates xmldoc content from the latest release/pre-release tags.
/// </summary>
/// <returns>The unique identifier of the enqueued job.</returns>
[Authorize("github")]
[Authorize("github", AuthenticationSchemes = "github")]
[HttpPost("admin/update/xmldoc")]
public async ValueTask<IActionResult> UpdateXmldocContent()
public IActionResult UpdateXmldocContent()
{
var parameters = new XmldocSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() };
var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateXmldocContent(parameters, null!));
logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for xmldoc sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId);

return await ValueTask.FromResult(Ok(jobId));
var jobId = hangfire.UpdateXmldocContent();
return Ok(jobId);
}

/// <summary>
/// Enqueues a job that gets the latest release/pre-release tags and their respective assets, and updates the installer download stats.
/// </summary>
/// <returns>The unique identifier of the enqueued job.</returns>
[Authorize("github")]
[Authorize("github", AuthenticationSchemes = "github")]
[HttpPost("admin/update/tags")]
public async ValueTask<IActionResult> UpdateTagMetadata()
public IActionResult UpdateTagMetadata()
{
var parameters = new TagSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() };
var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateInstallerDownloadStats(parameters, null!));
logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for tag sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId);

return await ValueTask.FromResult(Ok(jobId));
var jobId = hangfire.UpdateTagMetadata();
return Ok(jobId);
}

#if DEBUG
[HttpGet("admin/config/current")]
public async ValueTask<IActionResult> Config()
public IActionResult Config()
{
return await ValueTask.FromResult(Ok(options));
return Ok(options);
}
#endif
}
Expand Down
18 changes: 18 additions & 0 deletions rubberduckvba.Server/Api/Admin/GitRef.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace rubberduckvba.Server.Api.Admin;

public readonly record struct GitRef
{
private readonly string _value;

public GitRef(string value)
{
_value = value;
IsTag = value?.StartsWith("refs/tags/") ?? false;
Name = value?.Split('/').Last() ?? string.Empty;
}

public bool IsTag { get; }
public string Name { get; }

public override string ToString() => _value;
}
43 changes: 43 additions & 0 deletions rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Hangfire;
using rubberduckvba.Server;
using rubberduckvba.Server.ContentSynchronization;
using rubberduckvba.Server.Hangfire;
using rubberduckvba.Server.Services;

namespace rubberduckvba.Server.Api.Admin;

public class HangfireLauncherService(IBackgroundJobClient backgroundJob, ILogger<HangfireLauncherService> logger)
{
public string UpdateXmldocContent()
{
var parameters = new XmldocSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() };
var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateXmldocContent(parameters, null!));

if (string.IsNullOrWhiteSpace(jobId))
{
throw new InvalidOperationException("UpdateXmldocContent was requested but enqueueing a Hangfire job did not return a JobId.");
}
else
{
logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for xmldoc sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId);
}

return jobId;
}

public string UpdateTagMetadata()
{
var parameters = new TagSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() };
var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateInstallerDownloadStats(parameters, null!));

if (string.IsNullOrWhiteSpace(jobId))
{
throw new InvalidOperationException("UpdateXmldocContent was requested but enqueueing a Hangfire job did not return a JobId.");
}
else
{
logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for tag sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId);
}
return jobId;
}
}
46 changes: 46 additions & 0 deletions rubberduckvba.Server/Api/Admin/WebhookController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;

namespace rubberduckvba.Server.Api.Admin;

[ApiController]
public class WebhookController : RubberduckApiController
{
private readonly WebhookPayloadValidationService _validator;
private readonly HangfireLauncherService _hangfire;

public WebhookController(
ILogger<WebhookController> logger,
HangfireLauncherService hangfire,
WebhookPayloadValidationService validator)
: base(logger)
{
_validator = validator;
_hangfire = hangfire;
}

[Authorize("webhook", AuthenticationSchemes = "webhook-signature")]
[HttpPost("webhook/github")]
public IActionResult GitHub([FromBody] JToken payload)
{
var eventType = _validator.Validate(payload, Request.Headers, out var content, out var gitref);

if (eventType == WebhookPayloadType.Push)
{
var jobId = _hangfire.UpdateXmldocContent();
var message = $"Webhook push event was accepted. Tag '{gitref?.Name}' associated to the payload will be processed by JobId '{jobId}'.";

Logger.LogInformation(message);
return Ok(message);
}
else if (eventType == WebhookPayloadType.Greeting)
{
Logger.LogInformation("Webhook push event was accepted; nothing to process. {content}", content);
return string.IsNullOrWhiteSpace(content) ? NoContent() : Ok(content);
}

// reject the payload
return BadRequest();
}
}
8 changes: 8 additions & 0 deletions rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace rubberduckvba.Server.Api.Admin;

public enum WebhookPayloadType
{
Unsupported,
Greeting,
Push
}
39 changes: 39 additions & 0 deletions rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Newtonsoft.Json.Linq;

namespace rubberduckvba.Server.Api.Admin;

public class WebhookPayloadValidationService(ConfigurationOptions options)
{
public WebhookPayloadType Validate(JToken payload, IHeaderDictionary headers, out string? content, out GitRef? gitref)
{
content = default;
gitref = default;

if (!IsValidHeaders(headers) || !IsValidSource(payload) || !IsValidEvent(payload))
{
return WebhookPayloadType.Unsupported;
}

gitref = new GitRef(payload.Value<string>("ref"));
if (!(payload.Value<bool>("created") && gitref.HasValue && gitref.Value.IsTag))
{
content = payload.Value<string>("zen");
return WebhookPayloadType.Greeting;
}

return WebhookPayloadType.Push;
}

private bool IsValidHeaders(IHeaderDictionary headers) =>
headers.TryGetValue("X-GitHub-Event", out Microsoft.Extensions.Primitives.StringValues values) && values.Contains("push");

private bool IsValidSource(JToken payload) =>
payload["repository"].Value<string>("name") == options.GitHubOptions.Value.Rubberduck &&
payload["owner"].Value<int>("id") == options.GitHubOptions.Value.RubberduckOrgId;

private bool IsValidEvent(JToken payload)
{
var ev = payload["hook"]?["events"]?.Values<string>() ?? [];
return ev.Contains("push");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ protected override async Task ActionAsync(SyncRequestParameters input)
{
Context.LoadParameters(input);

var githubTags = await _github.GetAllTagsAsync();

// LoadInspectionDefaultConfig
var config = await _github.GetCodeAnalysisDefaultsConfigAsync();
Context.LoadInspectionDefaultConfig(config);
Expand All @@ -108,10 +110,44 @@ await Task.WhenAll([
]);

// AcquireDbTags
var dbMain = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: false);
Context.LoadRubberduckDbMain(dbMain);
var ghMain = githubTags.Where(tag => !tag.IsPreRelease).OrderByDescending(tag => tag.DateCreated).ThenByDescending(tag => tag.ReleaseId).Take(1).Single();
var ghNext = githubTags.Where(tag => tag.IsPreRelease).OrderByDescending(tag => tag.DateCreated).ThenByDescending(tag => tag.ReleaseId).Take(1).Single();

await Task.Delay(TimeSpan.FromSeconds(2)); // just in case the tags job was scheduled at/around the same time

var dbMain = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: false);
var dbNext = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: true);

var dbTags = _tagServices.GetAllTags().ToDictionary(e => e.Name);
List<TagGraph> newTags = [];
if (ghMain.Name != dbMain.Name)
{
if (!dbTags.ContainsKey(ghMain.Name))
{
newTags.Add(ghMain);
}
else
{
// that's an old tag then; do not process
throw new InvalidOperationException($"Tag metadata mismatch, xmldoc update will not proceed; GitHub@main:{ghMain.Name} ({ghMain.DateCreated}) | rubberduckdb@main: {dbMain.Name} ({dbMain.DateCreated})");
}
}
if (ghNext.Name != dbNext.Name)
{
if (!dbTags.ContainsKey(ghMain.Name))
{
newTags.Add(ghMain);
}
else
{
// that's an old tag then; do not process
throw new InvalidOperationException($"Tag metadata mismatch, xmldoc update will not proceed; GitHub@main:{ghMain.Name} ({ghMain.DateCreated}) | rubberduckdb@main: {dbMain.Name} ({dbMain.DateCreated})");
}
}

_tagServices.Create(newTags);

Context.LoadRubberduckDbMain(dbMain);
Context.LoadRubberduckDbNext(dbNext);

Context.LoadDbTags([dbMain, dbNext]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,4 @@ public record class XmldocSyncRequestParameters : SyncRequestParameters

public record class TagSyncRequestParameters : SyncRequestParameters
{
public string? Tag { get; init; }
}
77 changes: 77 additions & 0 deletions rubberduckvba.Server/GitHubAuthenticationHandler.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@

using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using rubberduckvba.Server.Api.Admin;
using rubberduckvba.Server.Services;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;

namespace rubberduckvba.Server;
Expand Down Expand Up @@ -32,3 +35,77 @@ protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
: AuthenticateResult.NoResult();
}
}

public class WebhookAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly ConfigurationOptions _configuration;

public WebhookAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
ConfigurationOptions configuration)
: base(options, logger, encoder)
{
_configuration = configuration;
}

protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
{
return await Task.Run(() =>
{
var xGitHubEvent = Context.Request.Headers["X-GitHub-Event"];
var xGitHubDelivery = Context.Request.Headers["X-GitHub-Delivery"];
var xHubSignature = Context.Request.Headers["X-Hub-Signature"];
var xHubSignature256 = Context.Request.Headers["X-Hub-Signature-256"];

if (!xGitHubEvent.Contains("push"))
{
// only authenticate push events
return AuthenticateResult.NoResult();
}

if (!Guid.TryParse(xGitHubDelivery.SingleOrDefault(), out _))
{
// delivery should parse as a GUID
return AuthenticateResult.NoResult();
}

if (!xHubSignature.Any())
{
// signature header should be present
return AuthenticateResult.NoResult();
}

var signature = xHubSignature256.SingleOrDefault();
var payload = new StreamReader(Context.Request.Body).ReadToEnd();

if (!IsValidSignature(signature, payload))
{
// encrypted signature must be present
return AuthenticateResult.NoResult();
}

var identity = new ClaimsIdentity("webhook", ClaimTypes.Name, ClaimTypes.Role);
identity.AddClaim(new Claim(ClaimTypes.Name, "rubberduck-vba-releasebot"));
identity.AddClaim(new Claim(ClaimTypes.Role, "rubberduck-webhook"));
identity.AddClaim(new Claim(ClaimTypes.Authentication, "webhook-signature"));

var principal = new ClaimsPrincipal(identity);
return AuthenticateResult.Success(new AuthenticationTicket(principal, "webhook-signature"));
});
}

private bool IsValidSignature(string? signature, string payload)
{
if (string.IsNullOrWhiteSpace(signature))
{
return false;
}

using var sha256 = SHA256.Create();

var secret = _configuration.GitHubOptions.Value.WebhookToken;
var bytes = Encoding.UTF8.GetBytes(secret + payload);
var check = $"sha256={Encoding.UTF8.GetString(sha256.ComputeHash(bytes))}";

return check == payload;
}
}
Loading

0 comments on commit a0bdd41

Please sign in to comment.