Skip to content

Commit a0bdd41

Browse files
authored
Merge pull request #37 from rubberduck-vba/webhook
Add webhook controller
2 parents c3369ee + 7e2269b commit a0bdd41

File tree

12 files changed

+335
-70
lines changed

12 files changed

+335
-70
lines changed

rubberduckvba.Server/Api/Admin/AdminController.cs

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,42 @@
1-
using Hangfire;
2-
using Microsoft.AspNetCore.Authorization;
1+
using Microsoft.AspNetCore.Authorization;
32
using Microsoft.AspNetCore.Mvc;
43
using Microsoft.Extensions.Options;
5-
using rubberduckvba.Server;
6-
using rubberduckvba.Server.ContentSynchronization;
7-
using rubberduckvba.Server.Hangfire;
8-
using rubberduckvba.Server.Services;
94

105
namespace rubberduckvba.Server.Api.Admin;
116

127

138
[ApiController]
14-
public class AdminController(ConfigurationOptions options, IBackgroundJobClient backgroundJob, ILogger<AdminController> logger) : ControllerBase
9+
public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire) : ControllerBase
1510
{
1611
/// <summary>
1712
/// Enqueues a job that updates xmldoc content from the latest release/pre-release tags.
1813
/// </summary>
1914
/// <returns>The unique identifier of the enqueued job.</returns>
20-
[Authorize("github")]
15+
[Authorize("github", AuthenticationSchemes = "github")]
2116
[HttpPost("admin/update/xmldoc")]
22-
public async ValueTask<IActionResult> UpdateXmldocContent()
17+
public IActionResult UpdateXmldocContent()
2318
{
24-
var parameters = new XmldocSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() };
25-
var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateXmldocContent(parameters, null!));
26-
logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for xmldoc sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId);
27-
28-
return await ValueTask.FromResult(Ok(jobId));
19+
var jobId = hangfire.UpdateXmldocContent();
20+
return Ok(jobId);
2921
}
3022

3123
/// <summary>
3224
/// Enqueues a job that gets the latest release/pre-release tags and their respective assets, and updates the installer download stats.
3325
/// </summary>
3426
/// <returns>The unique identifier of the enqueued job.</returns>
35-
[Authorize("github")]
27+
[Authorize("github", AuthenticationSchemes = "github")]
3628
[HttpPost("admin/update/tags")]
37-
public async ValueTask<IActionResult> UpdateTagMetadata()
29+
public IActionResult UpdateTagMetadata()
3830
{
39-
var parameters = new TagSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() };
40-
var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateInstallerDownloadStats(parameters, null!));
41-
logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for tag sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId);
42-
43-
return await ValueTask.FromResult(Ok(jobId));
31+
var jobId = hangfire.UpdateTagMetadata();
32+
return Ok(jobId);
4433
}
4534

4635
#if DEBUG
4736
[HttpGet("admin/config/current")]
48-
public async ValueTask<IActionResult> Config()
37+
public IActionResult Config()
4938
{
50-
return await ValueTask.FromResult(Ok(options));
39+
return Ok(options);
5140
}
5241
#endif
5342
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace rubberduckvba.Server.Api.Admin;
2+
3+
public readonly record struct GitRef
4+
{
5+
private readonly string _value;
6+
7+
public GitRef(string value)
8+
{
9+
_value = value;
10+
IsTag = value?.StartsWith("refs/tags/") ?? false;
11+
Name = value?.Split('/').Last() ?? string.Empty;
12+
}
13+
14+
public bool IsTag { get; }
15+
public string Name { get; }
16+
17+
public override string ToString() => _value;
18+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using Hangfire;
2+
using rubberduckvba.Server;
3+
using rubberduckvba.Server.ContentSynchronization;
4+
using rubberduckvba.Server.Hangfire;
5+
using rubberduckvba.Server.Services;
6+
7+
namespace rubberduckvba.Server.Api.Admin;
8+
9+
public class HangfireLauncherService(IBackgroundJobClient backgroundJob, ILogger<HangfireLauncherService> logger)
10+
{
11+
public string UpdateXmldocContent()
12+
{
13+
var parameters = new XmldocSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() };
14+
var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateXmldocContent(parameters, null!));
15+
16+
if (string.IsNullOrWhiteSpace(jobId))
17+
{
18+
throw new InvalidOperationException("UpdateXmldocContent was requested but enqueueing a Hangfire job did not return a JobId.");
19+
}
20+
else
21+
{
22+
logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for xmldoc sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId);
23+
}
24+
25+
return jobId;
26+
}
27+
28+
public string UpdateTagMetadata()
29+
{
30+
var parameters = new TagSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() };
31+
var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateInstallerDownloadStats(parameters, null!));
32+
33+
if (string.IsNullOrWhiteSpace(jobId))
34+
{
35+
throw new InvalidOperationException("UpdateXmldocContent was requested but enqueueing a Hangfire job did not return a JobId.");
36+
}
37+
else
38+
{
39+
logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for tag sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId);
40+
}
41+
return jobId;
42+
}
43+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Newtonsoft.Json.Linq;
4+
5+
namespace rubberduckvba.Server.Api.Admin;
6+
7+
[ApiController]
8+
public class WebhookController : RubberduckApiController
9+
{
10+
private readonly WebhookPayloadValidationService _validator;
11+
private readonly HangfireLauncherService _hangfire;
12+
13+
public WebhookController(
14+
ILogger<WebhookController> logger,
15+
HangfireLauncherService hangfire,
16+
WebhookPayloadValidationService validator)
17+
: base(logger)
18+
{
19+
_validator = validator;
20+
_hangfire = hangfire;
21+
}
22+
23+
[Authorize("webhook", AuthenticationSchemes = "webhook-signature")]
24+
[HttpPost("webhook/github")]
25+
public IActionResult GitHub([FromBody] JToken payload)
26+
{
27+
var eventType = _validator.Validate(payload, Request.Headers, out var content, out var gitref);
28+
29+
if (eventType == WebhookPayloadType.Push)
30+
{
31+
var jobId = _hangfire.UpdateXmldocContent();
32+
var message = $"Webhook push event was accepted. Tag '{gitref?.Name}' associated to the payload will be processed by JobId '{jobId}'.";
33+
34+
Logger.LogInformation(message);
35+
return Ok(message);
36+
}
37+
else if (eventType == WebhookPayloadType.Greeting)
38+
{
39+
Logger.LogInformation("Webhook push event was accepted; nothing to process. {content}", content);
40+
return string.IsNullOrWhiteSpace(content) ? NoContent() : Ok(content);
41+
}
42+
43+
// reject the payload
44+
return BadRequest();
45+
}
46+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace rubberduckvba.Server.Api.Admin;
2+
3+
public enum WebhookPayloadType
4+
{
5+
Unsupported,
6+
Greeting,
7+
Push
8+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using Newtonsoft.Json.Linq;
2+
3+
namespace rubberduckvba.Server.Api.Admin;
4+
5+
public class WebhookPayloadValidationService(ConfigurationOptions options)
6+
{
7+
public WebhookPayloadType Validate(JToken payload, IHeaderDictionary headers, out string? content, out GitRef? gitref)
8+
{
9+
content = default;
10+
gitref = default;
11+
12+
if (!IsValidHeaders(headers) || !IsValidSource(payload) || !IsValidEvent(payload))
13+
{
14+
return WebhookPayloadType.Unsupported;
15+
}
16+
17+
gitref = new GitRef(payload.Value<string>("ref"));
18+
if (!(payload.Value<bool>("created") && gitref.HasValue && gitref.Value.IsTag))
19+
{
20+
content = payload.Value<string>("zen");
21+
return WebhookPayloadType.Greeting;
22+
}
23+
24+
return WebhookPayloadType.Push;
25+
}
26+
27+
private bool IsValidHeaders(IHeaderDictionary headers) =>
28+
headers.TryGetValue("X-GitHub-Event", out Microsoft.Extensions.Primitives.StringValues values) && values.Contains("push");
29+
30+
private bool IsValidSource(JToken payload) =>
31+
payload["repository"].Value<string>("name") == options.GitHubOptions.Value.Rubberduck &&
32+
payload["owner"].Value<int>("id") == options.GitHubOptions.Value.RubberduckOrgId;
33+
34+
private bool IsValidEvent(JToken payload)
35+
{
36+
var ev = payload["hook"]?["events"]?.Values<string>() ?? [];
37+
return ev.Contains("push");
38+
}
39+
}

rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ protected override async Task ActionAsync(SyncRequestParameters input)
9090
{
9191
Context.LoadParameters(input);
9292

93+
var githubTags = await _github.GetAllTagsAsync();
94+
9395
// LoadInspectionDefaultConfig
9496
var config = await _github.GetCodeAnalysisDefaultsConfigAsync();
9597
Context.LoadInspectionDefaultConfig(config);
@@ -108,10 +110,44 @@ await Task.WhenAll([
108110
]);
109111

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

116+
await Task.Delay(TimeSpan.FromSeconds(2)); // just in case the tags job was scheduled at/around the same time
117+
118+
var dbMain = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: false);
114119
var dbNext = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: true);
120+
121+
var dbTags = _tagServices.GetAllTags().ToDictionary(e => e.Name);
122+
List<TagGraph> newTags = [];
123+
if (ghMain.Name != dbMain.Name)
124+
{
125+
if (!dbTags.ContainsKey(ghMain.Name))
126+
{
127+
newTags.Add(ghMain);
128+
}
129+
else
130+
{
131+
// that's an old tag then; do not process
132+
throw new InvalidOperationException($"Tag metadata mismatch, xmldoc update will not proceed; GitHub@main:{ghMain.Name} ({ghMain.DateCreated}) | rubberduckdb@main: {dbMain.Name} ({dbMain.DateCreated})");
133+
}
134+
}
135+
if (ghNext.Name != dbNext.Name)
136+
{
137+
if (!dbTags.ContainsKey(ghMain.Name))
138+
{
139+
newTags.Add(ghMain);
140+
}
141+
else
142+
{
143+
// that's an old tag then; do not process
144+
throw new InvalidOperationException($"Tag metadata mismatch, xmldoc update will not proceed; GitHub@main:{ghMain.Name} ({ghMain.DateCreated}) | rubberduckdb@main: {dbMain.Name} ({dbMain.DateCreated})");
145+
}
146+
}
147+
148+
_tagServices.Create(newTags);
149+
150+
Context.LoadRubberduckDbMain(dbMain);
115151
Context.LoadRubberduckDbNext(dbNext);
116152

117153
Context.LoadDbTags([dbMain, dbNext]);

rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,4 @@ public record class XmldocSyncRequestParameters : SyncRequestParameters
2424

2525
public record class TagSyncRequestParameters : SyncRequestParameters
2626
{
27-
public string? Tag { get; init; }
2827
}

rubberduckvba.Server/GitHubAuthenticationHandler.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11

22
using Microsoft.AspNetCore.Authentication;
33
using Microsoft.Extensions.Options;
4+
using rubberduckvba.Server.Api.Admin;
45
using rubberduckvba.Server.Services;
56
using System.Security.Claims;
7+
using System.Security.Cryptography;
8+
using System.Text;
69
using System.Text.Encodings.Web;
710

811
namespace rubberduckvba.Server;
@@ -32,3 +35,77 @@ protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
3235
: AuthenticateResult.NoResult();
3336
}
3437
}
38+
39+
public class WebhookAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
40+
{
41+
private readonly ConfigurationOptions _configuration;
42+
43+
public WebhookAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder,
44+
ConfigurationOptions configuration)
45+
: base(options, logger, encoder)
46+
{
47+
_configuration = configuration;
48+
}
49+
50+
protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
51+
{
52+
return await Task.Run(() =>
53+
{
54+
var xGitHubEvent = Context.Request.Headers["X-GitHub-Event"];
55+
var xGitHubDelivery = Context.Request.Headers["X-GitHub-Delivery"];
56+
var xHubSignature = Context.Request.Headers["X-Hub-Signature"];
57+
var xHubSignature256 = Context.Request.Headers["X-Hub-Signature-256"];
58+
59+
if (!xGitHubEvent.Contains("push"))
60+
{
61+
// only authenticate push events
62+
return AuthenticateResult.NoResult();
63+
}
64+
65+
if (!Guid.TryParse(xGitHubDelivery.SingleOrDefault(), out _))
66+
{
67+
// delivery should parse as a GUID
68+
return AuthenticateResult.NoResult();
69+
}
70+
71+
if (!xHubSignature.Any())
72+
{
73+
// signature header should be present
74+
return AuthenticateResult.NoResult();
75+
}
76+
77+
var signature = xHubSignature256.SingleOrDefault();
78+
var payload = new StreamReader(Context.Request.Body).ReadToEnd();
79+
80+
if (!IsValidSignature(signature, payload))
81+
{
82+
// encrypted signature must be present
83+
return AuthenticateResult.NoResult();
84+
}
85+
86+
var identity = new ClaimsIdentity("webhook", ClaimTypes.Name, ClaimTypes.Role);
87+
identity.AddClaim(new Claim(ClaimTypes.Name, "rubberduck-vba-releasebot"));
88+
identity.AddClaim(new Claim(ClaimTypes.Role, "rubberduck-webhook"));
89+
identity.AddClaim(new Claim(ClaimTypes.Authentication, "webhook-signature"));
90+
91+
var principal = new ClaimsPrincipal(identity);
92+
return AuthenticateResult.Success(new AuthenticationTicket(principal, "webhook-signature"));
93+
});
94+
}
95+
96+
private bool IsValidSignature(string? signature, string payload)
97+
{
98+
if (string.IsNullOrWhiteSpace(signature))
99+
{
100+
return false;
101+
}
102+
103+
using var sha256 = SHA256.Create();
104+
105+
var secret = _configuration.GitHubOptions.Value.WebhookToken;
106+
var bytes = Encoding.UTF8.GetBytes(secret + payload);
107+
var check = $"sha256={Encoding.UTF8.GetString(sha256.ComputeHash(bytes))}";
108+
109+
return check == payload;
110+
}
111+
}

0 commit comments

Comments
 (0)