Skip to content

Webhook + Indenter #61

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

Merged
merged 4 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 30 additions & 31 deletions rubberduckvba.Server/Api/Admin/WebhookController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,36 +22,35 @@ public WebhookController(

[Authorize("webhook")]
[HttpPost("webhook/github")]
public async Task<IActionResult> GitHub([FromBody] dynamic body)
{
//var reader = new StreamReader(Request.Body);
//var json = await reader.ReadToEndAsync();
string json = body.ToString();

var payload = JsonSerializer.Deserialize<PushWebhookPayload>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("Could not deserialize payload");
var eventType = _validator.Validate(payload, json, 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.Ping)
{
Logger.LogInformation("Webhook ping event was accepted; nothing to process.");
return Ok();
}
else if (eventType == WebhookPayloadType.Greeting)
public async Task<IActionResult> GitHub([FromBody] dynamic body) =>
GuardInternalAction(() =>
{
Logger.LogInformation("Webhook push event was accepted; nothing to process. {content}", content);
return string.IsNullOrWhiteSpace(content) ? NoContent() : Ok(content);
}

// reject the payload
return BadRequest();
}
string json = body.ToString();

var payload = JsonSerializer.Deserialize<PushWebhookPayload>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("Could not deserialize payload");
var eventType = _validator.Validate(payload, json, 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.Ping)
{
Logger.LogInformation("Webhook ping event was accepted; nothing to process.");
return Ok();
}
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();
});
}
159 changes: 91 additions & 68 deletions rubberduckvba.Server/Api/Auth/AuthController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Octokit;
using Octokit.Internal;
Expand All @@ -23,91 +24,113 @@ public record class SignInViewModel
}

[ApiController]
public class AuthController(IOptions<GitHubSettings> configuration, IOptions<ApiSettings> api, ILogger<AuthController> logger) : ControllerBase
[AllowAnonymous]
public class AuthController : RubberduckApiController
{
private readonly IOptions<GitHubSettings> configuration;

public AuthController(IOptions<GitHubSettings> configuration, IOptions<ApiSettings> api, ILogger<AuthController> logger)
: base(logger)
{
this.configuration = configuration;
}

[HttpGet("auth")]
[AllowAnonymous]
public IActionResult Index()
{
var claims = HttpContext.User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value);
var hasName = claims.TryGetValue(ClaimTypes.Name, out var name);
var hasRole = claims.TryGetValue(ClaimTypes.Role, out var role);

if (hasName && hasRole)
return GuardInternalAction(() =>
{
if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(role))
var claims = HttpContext.User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value);
var hasName = claims.TryGetValue(ClaimTypes.Name, out var name);
var hasRole = claims.TryGetValue(ClaimTypes.Role, out var role);

if (hasName && hasRole)
{
return BadRequest();
if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(role))
{
return BadRequest();
}

var isAuthenticated = HttpContext.User.Identity?.IsAuthenticated ?? false;
var model = new UserViewModel
{
Name = name,
IsAuthenticated = isAuthenticated,
IsAdmin = role == configuration.Value.OwnerOrg
};

return Ok(model);
}

var isAuthenticated = HttpContext.User.Identity?.IsAuthenticated ?? false;
var model = new UserViewModel
else
{
Name = name,
IsAuthenticated = isAuthenticated,
IsAdmin = role == configuration.Value.OwnerOrg
};

return Ok(model);
}
else
{
return Ok(UserViewModel.Anonymous);
}
return Ok(UserViewModel.Anonymous);
}
});
}

[HttpPost("auth/signin")]
[AllowAnonymous]
public IActionResult SessionSignIn(SignInViewModel vm)
{
if (User.Identity?.IsAuthenticated ?? false)
return GuardInternalAction(() =>
{
logger.LogInformation("Signin was requested, but user is already authenticated. Redirecting to home page...");
return Redirect("/");
}
if (User.Identity?.IsAuthenticated ?? false)
{
Logger.LogInformation("Signin was requested, but user is already authenticated. Redirecting to home page...");
return Redirect("/");
}

var clientId = configuration.Value.ClientId;
var agent = configuration.Value.UserAgent;
var clientId = configuration.Value.ClientId;
var agent = configuration.Value.UserAgent;

var github = new GitHubClient(new ProductHeaderValue(agent));
var request = new OauthLoginRequest(clientId)
{
AllowSignup = false,
Scopes = { "read:user", "read:org" },
State = vm.State
};

logger.LogInformation("Requesting OAuth app GitHub login url...");
var url = github.Oauth.GetGitHubLoginUrl(request);
if (url is null)
{
logger.LogInformation("OAuth login was cancelled by the user or did not return a url.");
return Forbid();
}
var github = new GitHubClient(new ProductHeaderValue(agent));
var request = new OauthLoginRequest(clientId)
{
AllowSignup = false,
Scopes = { "read:user", "read:org" },
State = vm.State
};

logger.LogInformation("Returning the login url for the client to redirect. State: {xsrf}", vm.State);
return Ok(url.ToString());
Logger.LogInformation("Requesting OAuth app GitHub login url...");
var url = github.Oauth.GetGitHubLoginUrl(request);
if (url is null)
{
Logger.LogInformation("OAuth login was cancelled by the user or did not return a url.");
return Forbid();
}

Logger.LogInformation("Returning the login url for the client to redirect. State: {xsrf}", vm.State);
return Ok(url.ToString());
});
}

[HttpPost("auth/github")]
public async Task<IActionResult> OnGitHubCallback(SignInViewModel vm)
[AllowAnonymous]
public IActionResult OnGitHubCallback(SignInViewModel vm)
{
logger.LogInformation("OAuth token was received. State: {state}", vm.State);
var clientId = configuration.Value.ClientId;
var clientSecret = configuration.Value.ClientSecret;
var agent = configuration.Value.UserAgent;
return GuardInternalAction(() =>
{
Logger.LogInformation("OAuth token was received. State: {state}", vm.State);
var clientId = configuration.Value.ClientId;
var clientSecret = configuration.Value.ClientSecret;
var agent = configuration.Value.UserAgent;

var github = new GitHubClient(new ProductHeaderValue(agent));
var github = new GitHubClient(new ProductHeaderValue(agent));

var request = new OauthTokenRequest(clientId, clientSecret, vm.Code);
var token = await github.Oauth.CreateAccessToken(request);
if (token is null)
{
logger.LogWarning("OAuth access token was not created.");
return Unauthorized();
}
var request = new OauthTokenRequest(clientId, clientSecret, vm.Code);
var token = github.Oauth.CreateAccessToken(request).GetAwaiter().GetResult();
if (token is null)
{
Logger.LogWarning("OAuth access token was not created.");
return Unauthorized();
}

Logger.LogInformation("OAuth access token was created. Authorizing...");
var authorizedToken = AuthorizeAsync(token.AccessToken).GetAwaiter().GetResult();

logger.LogInformation("OAuth access token was created. Authorizing...");
var authorizedToken = await AuthorizeAsync(token.AccessToken);
return authorizedToken is null ? Unauthorized() : Ok(vm with { Token = authorizedToken });
return authorizedToken is null ? Unauthorized() : Ok(vm with { Token = authorizedToken });
});
}

private async Task<string?> AuthorizeAsync(string token)
Expand All @@ -119,13 +142,13 @@ public async Task<IActionResult> OnGitHubCallback(SignInViewModel vm)
var githubUser = await github.User.Current();
if (githubUser.Suspended)
{
logger.LogWarning("User {name} with login '{login}' ({url}) is a suspended GitHub account and will not be authorized.", githubUser.Name, githubUser.Login, githubUser.Url);
Logger.LogWarning("User login '{login}' ({name}) is a suspended GitHub account and will not be authorized.", githubUser.Login, githubUser.Name);
return default;
}

var identity = new ClaimsIdentity("github", ClaimTypes.Name, ClaimTypes.Role);
identity.AddClaim(new Claim(ClaimTypes.Name, githubUser.Login));
logger.LogInformation("Creating claims identity for GitHub login '{login}'...", githubUser.Login);
Logger.LogInformation("Creating claims identity for GitHub login '{login}'...", githubUser.Login);

var orgs = await github.Organization.GetAllForUser(githubUser.Login);
var rdOrg = orgs.SingleOrDefault(org => org.Id == configuration.Value.RubberduckOrgId);
Expand All @@ -135,27 +158,27 @@ public async Task<IActionResult> OnGitHubCallback(SignInViewModel vm)
identity.AddClaim(new Claim(ClaimTypes.Role, configuration.Value.OwnerOrg));
identity.AddClaim(new Claim(ClaimTypes.Authentication, token));
identity.AddClaim(new Claim("access_token", token));
logger.LogDebug("GitHub Organization claims were granted. Creating claims principal...");
Logger.LogDebug("GitHub Organization claims were granted. Creating claims principal...");

var principal = new ClaimsPrincipal(identity);
var roles = string.Join(",", identity.Claims.Where(claim => claim.Type == ClaimTypes.Role).Select(claim => claim.Value));

HttpContext.User = principal;
Thread.CurrentPrincipal = HttpContext.User;

logger.LogInformation("GitHub user with login {login} has signed in with role authorizations '{role}'.", githubUser.Login, configuration.Value.OwnerOrg);
Logger.LogInformation("GitHub user with login {login} has signed in with role authorizations '{role}'.", githubUser.Login, configuration.Value.OwnerOrg);
return token;
}
else
{
logger.LogWarning("User {name} ({email}) with login '{login}' is not a member of organization ID {org} and will not be authorized.", githubUser.Name, githubUser.Email, githubUser.Login, configuration.Value.RubberduckOrgId);
Logger.LogWarning("User {name} ({email}) with login '{login}' is not a member of organization ID {org} and will not be authorized.", githubUser.Name, githubUser.Email, githubUser.Login, configuration.Value.RubberduckOrgId);
return default;
}
}
catch (Exception)
{
// just ignore: configuration needs the org (prod) client app id to avoid throwing this exception
logger.LogWarning("An exception was thrown. Verify GitHub:ClientId and GitHub:ClientSecret configuration; authorization fails.");
Logger.LogWarning("An exception was thrown. Verify GitHub:ClientId and GitHub:ClientSecret configuration; authorization fails.");
return default;
}
}
Expand Down
63 changes: 63 additions & 0 deletions rubberduckvba.Server/Api/Indenter/IndenterController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RubberduckServices;

namespace rubberduckvba.Server.Api.Indenter;

[AllowAnonymous]
public class IndenterController : RubberduckApiController
{
private readonly IIndenterService service;

public IndenterController(IIndenterService service, ILogger<IndenterController> logger)
: base(logger)
{
this.service = service;
}

[HttpGet("indenter/version")]
[AllowAnonymous]
public IActionResult Version() =>
GuardInternalAction(() => Ok(service.IndenterVersion()));

[HttpGet("indenter/defaults")]
[AllowAnonymous]
public IActionResult DefaultSettings() =>
GuardInternalAction(() =>
{
var result = new IndenterViewModel
{
IndenterVersion = service.IndenterVersion(),
Code = "Option Explicit\n\n'...comments...\n\nPublic Sub DoSomething()\n'...comments...\n\nEnd Sub\nPublic Sub DoSomethingElse()\n'...comments...\n\nIf True Then\nMsgBox \"Hello, world!\"\nElse\n'...comments...\nExit Sub\nEnd If\nEnd Sub\n",
AlignCommentsWithCode = true,
EmptyLineHandlingMethod = IndenterEmptyLineHandling.Indent,
ForceCompilerDirectivesInColumn1 = true,
GroupRelatedProperties = false,
IndentSpaces = 4,
IndentCase = true,
IndentEntireProcedureBody = true,
IndentEnumTypeAsProcedure = true,
VerticallySpaceProcedures = true,
LinesBetweenProcedures = 1,
IndentFirstCommentBlock = true,
IndentFirstDeclarationBlock = true,
EndOfLineCommentStyle = IndenterEndOfLineCommentStyle.SameGap,
};

return Ok(result);
});

[HttpPost("indenter/indent")]
[AllowAnonymous]
public IActionResult Indent(IndenterViewModel model) =>
GuardInternalAction(() =>
{
if (model is null)
{
throw new ArgumentNullException(nameof(model));
}

var result = service.IndentAsync(model).GetAwaiter().GetResult();
return Ok(result);
});
}
Loading