diff --git a/.github/workflows/dotnet-cd-prod.yml b/.github/workflows/dotnet-cd-prod.yml new file mode 100644 index 0000000..a33ee96 --- /dev/null +++ b/.github/workflows/dotnet-cd-prod.yml @@ -0,0 +1,73 @@ +name: prod-deploy + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: windows-latest + environment: AZ-Prod + + steps: + - uses: actions/checkout@v4 + + - name: dependency caching + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: configure production front-end environment + run: | + set-content "rubberduckvba.client\src\environments\environment.prod.ts" -value "export const environment = { production: true, apiBaseUrl: '${{ vars.API_ROOT_URL }}' };" + set-content "rubberduckvba.client\src\environments\environment.ts" -value "export const environment = { production: true, apiBaseUrl: '${{ vars.API_ROOT_URL }}' };" + + - name: dotnet build + run: dotnet build rubberduckvba.Server --configuration Release + + - name: dotnet publish + run: dotnet publish "rubberduckvba.Server\rubberduckvba.Server.csproj" --configuration Release --output ${{env.DOTNET_ROOT}}\pub + + - name: upload artifacts + uses: actions/upload-artifact@v4 + with: + name: pub + path: ${{env.DOTNET_ROOT}}\pub + + deploy: + if: github.ref == 'refs/heads/main' + runs-on: self-hosted + needs: build + steps: + - name: prepare staging + run: remove-item C:/pub/webroot/rubberduckvba.com -Recurse -Force + + - name: download artifacts + uses: actions/download-artifact@v4.1.8 + with: + name: pub + path: C:/pub/pub-prod.zip + + - name: staging + run: move-item C:/pub/pub-prod.zip C:/pub/webroot/rubberduckvba.com -force + + - name: deploy iis site + run: | + stop-webapppool -name "rubberduckvba-prod" + stop-webapppool -name "api-prod" + stop-iissite -name api-prod -confirm: $false + stop-iissite -name rubberduckvba-prod -confirm: $false + start-sleep -seconds 10 + copy-item C:/pub/webroot/rubberduckvba.com/* C:/inetpub/wwwroot/rubberduckvba.com -Recurse -Force + copy-item C:/inetpub/appsettings.prod.json C:/inetpub/wwwroot/rubberduckvba.com/appsettings.json -Force + copy-item C:/inetpub/__Web.config C:/inetpub/wwwroot/rubberduckvba.com/wwwroot/browser/Web.config -Force + start-webapppool api-prod + start-iissite api-prod + start-webapppool rubberduckvba-prod + start-iissite rubberduckvba-prod + diff --git a/.github/workflows/dotnet-cd.yml b/.github/workflows/dotnet-cd.yml index 46272a6..f6f7249 100644 --- a/.github/workflows/dotnet-cd.yml +++ b/.github/workflows/dotnet-cd.yml @@ -10,12 +10,13 @@ permissions: jobs: build: - runs-on: self-hosted + runs-on: windows-latest + environment: AZ-Test steps: - uses: actions/checkout@v4 - - name: Set up dependency caching for faster builds + - name: dependency caching uses: actions/cache@v4 with: path: ~/.nuget/packages @@ -23,20 +24,51 @@ jobs: restore-keys: | ${{ runner.os }}-nuget- + - name: configure test front-end environment + run: | + set-content "rubberduckvba.client\src\environments\environment.test.ts" -value "export const environment = { production: false, apiBaseUrl: '${{ vars.API_ROOT_URL }}' };" + set-content "rubberduckvba.client\src\environments\environment.prod.ts" -value "export const environment = { production: false, apiBaseUrl: '${{ vars.API_ROOT_URL }}' };" + set-content "rubberduckvba.client\src\environments\environment.ts" -value "export const environment = { production: false, apiBaseUrl: '${{ vars.API_ROOT_URL }}' };" + - name: dotnet build run: dotnet build rubberduckvba.Server --configuration Release - name: dotnet publish run: dotnet publish "rubberduckvba.Server\rubberduckvba.Server.csproj" --configuration Release --output ${{env.DOTNET_ROOT}}\pub + - name: upload artifacts + uses: actions/upload-artifact@v4 + with: + name: pub + path: ${{env.DOTNET_ROOT}}\pub + deploy: runs-on: self-hosted needs: build steps: + - name: clear staging + run: remove-item C:/pub/webroot/test.rubberduckvba.com -Recurse -Force + + - name: download artifacts + uses: actions/download-artifact@v4.1.8 + with: + name: pub + path: C:/pub/pub-test.zip + + - name: staging + run: move-item C:/pub/pub-test.zip C:/pub/webroot/test.rubberduckvba.com -force + - name: deploy iis site run: | + stop-webapppool -name "DefaultAppPool" stop-webapppool -name "rubberduckvba" stop-iissite -name api -confirm: $false - copy-item C:/pub/* C:/inetpub/wwwroot -Recurse -Force + stop-iissite -name rubberduckvba -confirm: $false + start-sleep -seconds 10 + copy-item C:/pub/webroot/test.rubberduckvba.com/* C:/inetpub/wwwroot/test.rubberduckvba.com -Recurse -Force + copy-item C:/inetpub/appsettings.test.json C:/inetpub/wwwroot/test.rubberduckvba.com/appsettings.json -Force + copy-item C:/inetpub/__Web.config C:/inetpub/wwwroot/test.rubberduckvba.com/wwwroot/browser/Web.config -Force start-webapppool rubberduckvba start-iissite api + start-webapppool DefaultAppPool + start-iissite rubberduckvba diff --git a/rubberduckvba.Server/Api/Admin/AdminController.cs b/rubberduckvba.Server/Api/Admin/AdminController.cs index 8b2489a..c478ca3 100644 --- a/rubberduckvba.Server/Api/Admin/AdminController.cs +++ b/rubberduckvba.Server/Api/Admin/AdminController.cs @@ -1,31 +1,25 @@ -using Hangfire; -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; 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 logger) : ControllerBase +public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire, CacheService cache) : ControllerBase { /// /// Enqueues a job that updates xmldoc content from the latest release/pre-release tags. /// /// The unique identifier of the enqueued job. [Authorize("github")] + [EnableCors(CorsPolicies.AllowAuthenticated)] [HttpPost("admin/update/xmldoc")] - public async ValueTask 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); } /// @@ -33,21 +27,30 @@ public async ValueTask UpdateXmldocContent() /// /// The unique identifier of the enqueued job. [Authorize("github")] + [EnableCors(CorsPolicies.AllowAuthenticated)] [HttpPost("admin/update/tags")] - public async ValueTask 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); + var jobId = hangfire.UpdateTagMetadata(); + return Ok(jobId); + } - return await ValueTask.FromResult(Ok(jobId)); + [Authorize("github")] + [EnableCors(CorsPolicies.AllowAuthenticated)] + [HttpPost("admin/cache/clear")] + public IActionResult ClearCache() + { + cache.Clear(); + return Ok(); } #if DEBUG + [AllowAnonymous] + [EnableCors(CorsPolicies.AllowAll)] [HttpGet("admin/config/current")] - public async ValueTask Config() + public IActionResult Config() { - return await ValueTask.FromResult(Ok(options)); + return Ok(options); } #endif } diff --git a/rubberduckvba.Server/Api/Admin/GitRef.cs b/rubberduckvba.Server/Api/Admin/GitRef.cs new file mode 100644 index 0000000..fadb9a1 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/GitRef.cs @@ -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; +} diff --git a/rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs b/rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs new file mode 100644 index 0000000..41fac4e --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs @@ -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 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; + } +} diff --git a/rubberduckvba.Server/Api/Admin/PushWebhookPayload.cs b/rubberduckvba.Server/Api/Admin/PushWebhookPayload.cs new file mode 100644 index 0000000..154f6a1 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/PushWebhookPayload.cs @@ -0,0 +1,32 @@ +namespace rubberduckvba.Server.Api.Admin; + +public record class PushWebhookPayload +{ + public string Ref { get; set; } + + public bool Created { get; set; } + public bool Deleted { get; set; } + public bool Forced { get; set; } + + public WebhookPayloadSender Sender { get; set; } + public WebhookPayloadRepository Repository { get; set; } + + public record class WebhookPayloadSender + { + public string Login { get; set; } + } + + public record class WebhookPayloadRepository + { + public int Id { get; set; } + public string Name { get; set; } + public WebhookPayloadRepositoryOwner Owner { get; set; } + + public record class WebhookPayloadRepositoryOwner + { + public int Id { get; set; } + public string Login { get; set; } + public string Type { get; set; } + } + } +} diff --git a/rubberduckvba.Server/Api/Admin/WebhookController.cs b/rubberduckvba.Server/Api/Admin/WebhookController.cs new file mode 100644 index 0000000..94cf711 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/WebhookController.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace rubberduckvba.Server.Api.Admin; + +[ApiController] +public class WebhookController : RubberduckApiController +{ + private readonly WebhookPayloadValidationService _validator; + private readonly HangfireLauncherService _hangfire; + + public WebhookController( + ILogger logger, + HangfireLauncherService hangfire, + WebhookPayloadValidationService validator) + : base(logger) + { + _validator = validator; + _hangfire = hangfire; + } + + [Authorize("webhook")] + [EnableCors(CorsPolicies.AllowAll)] + [HttpPost("webhook/github")] + public async Task GitHub([FromBody] dynamic body) => + GuardInternalAction(() => + { + string json = body.ToString(); + + var payload = JsonSerializer.Deserialize(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(); + }); +} diff --git a/rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs b/rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs new file mode 100644 index 0000000..3ab8bab --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs @@ -0,0 +1,9 @@ +namespace rubberduckvba.Server.Api.Admin; + +public enum WebhookPayloadType +{ + BadRequest, + Greeting, + Ping, + Push +} diff --git a/rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs b/rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs new file mode 100644 index 0000000..a904273 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs @@ -0,0 +1,31 @@ +namespace rubberduckvba.Server.Api.Admin; + +public class WebhookPayloadValidationService(ConfigurationOptions options, WebhookSignatureValidationService signatureValidation) +{ + public WebhookPayloadType Validate(PushWebhookPayload payload, string body, IHeaderDictionary headers, out string? content, out GitRef? gitref) + { + content = default; + if (!signatureValidation.Validate(body, headers["X-Hub-Signature-256"].OfType().ToArray())) + { + gitref = default; + return WebhookPayloadType.BadRequest; + } + + gitref = new GitRef(payload.Ref); + if (headers["X-GitHub-Event"].FirstOrDefault() == "ping") + { + if (!(payload.Created && gitref.Value.IsTag)) + { + return WebhookPayloadType.Greeting; + } + return WebhookPayloadType.Ping; + } + + if (headers["X-GitHub-Event"].FirstOrDefault() == "push") + { + return WebhookPayloadType.Push; + } + + return WebhookPayloadType.BadRequest; + } +} diff --git a/rubberduckvba.Server/Api/Auth/AuthController.cs b/rubberduckvba.Server/Api/Auth/AuthController.cs index 10c8c3d..31f50b6 100644 --- a/rubberduckvba.Server/Api/Auth/AuthController.cs +++ b/rubberduckvba.Server/Api/Auth/AuthController.cs @@ -1,119 +1,143 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Octokit; using Octokit.Internal; using System.Security.Claims; -using System.Text; namespace rubberduckvba.Server.Api.Auth; public record class UserViewModel { - public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", HasOrgRole = false }; + public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", IsAuthenticated = false, IsAdmin = false }; public string Name { get; init; } = default!; - public bool HasOrgRole { get; init; } + public bool IsAuthenticated { get; init; } + public bool IsAdmin { get; init; } } - +public record class SignInViewModel +{ + public string? State { get; init; } + public string? Code { get; init; } + public string? Token { get; init; } +} [ApiController] [AllowAnonymous] -public class AuthController(IOptions configuration, IOptions api) : ControllerBase +public class AuthController : RubberduckApiController { + private readonly IOptions configuration; + + public AuthController(IOptions configuration, IOptions api, ILogger logger) + : base(logger) + { + this.configuration = configuration; + } + [HttpGet("auth")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public ActionResult Index() + 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)) - { - return BadRequest(); - } + 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); - var model = new UserViewModel + if (hasName && hasRole) { - Name = name, - HasOrgRole = (HttpContext.User.Identity?.IsAuthenticated ?? false) && role == configuration.Value.OwnerOrg - }; + if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(role)) + { + return BadRequest(); + } - return Ok(model); - } - else - { - return Ok(UserViewModel.Anonymous); - } + var isAuthenticated = HttpContext.User.Identity?.IsAuthenticated ?? false; + var model = new UserViewModel + { + Name = name, + IsAuthenticated = isAuthenticated, + IsAdmin = role == configuration.Value.OwnerOrg + }; + + return Ok(model); + } + else + { + return Ok(UserViewModel.Anonymous); + } + }); } [HttpPost("auth/signin")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task SignIn() + public IActionResult SessionSignIn(SignInViewModel vm) { - var xsrf = Guid.NewGuid().ToString(); - HttpContext.Session.SetString("xsrf:state", xsrf); - await HttpContext.Session.CommitAsync(); + return GuardInternalAction(() => + { + 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 = xsrf - }; + var github = new GitHubClient(new ProductHeaderValue(agent)); + var request = new OauthLoginRequest(clientId) + { + AllowSignup = false, + Scopes = { "read:user", "read:org" }, + State = vm.State + }; - var url = github.Oauth.GetGitHubLoginUrl(request); - if (url is null) - { - return Forbid(); - } + 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(); + } - // TODO log url - //return Redirect(url.ToString()); - return RedirectToAction("Index", "Home"); + Logger.LogInformation("Returning the login url for the client to redirect. State: {xsrf}", vm.State); + return Ok(url.ToString()); + }); } - [HttpGet("auth/github")] + [HttpPost("auth/github")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task GitHubCallback(string code, string state) + public IActionResult OnGitHubCallback(SignInViewModel vm) { - if (string.IsNullOrWhiteSpace(code)) - { - return BadRequest(); - } - - var expected = HttpContext.Session.GetString("xsrf:state"); - HttpContext.Session.Clear(); - await HttpContext.Session.CommitAsync(); - - if (state != expected) + return GuardInternalAction(() => { - return BadRequest(); - } + 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 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, code); - var token = await github.Oauth.CreateAccessToken(request); + 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(); + } - await AuthorizeAsync(token.AccessToken); + Logger.LogInformation("OAuth access token was created. Authorizing..."); + var authorizedToken = AuthorizeAsync(token.AccessToken).GetAwaiter().GetResult(); - return Ok(); + return authorizedToken is null ? Unauthorized() : Ok(vm with { Token = authorizedToken }); + }); } - private async Task AuthorizeAsync(string token) + private async Task AuthorizeAsync(string token) { try { @@ -122,42 +146,44 @@ private async Task AuthorizeAsync(string token) var githubUser = await github.User.Current(); if (githubUser.Suspended) { - throw new InvalidOperationException("User is suspended"); + Logger.LogWarning("User login '{login}' ({name}) is a suspended GitHub account and will not be authorized.", githubUser.Login, githubUser.Name); + return default; } - var emailClaim = new Claim(ClaimTypes.Email, githubUser.Email); - var identity = new ClaimsIdentity("github", ClaimTypes.Name, ClaimTypes.Role); - if (identity != null) - { - identity.AddClaim(new Claim(ClaimTypes.Name, githubUser.Login)); + identity.AddClaim(new Claim(ClaimTypes.Name, 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); + var orgs = await github.Organization.GetAllForUser(githubUser.Login); + var rdOrg = orgs.SingleOrDefault(org => org.Id == configuration.Value.RubberduckOrgId); - if (rdOrg != null) - { - identity.AddClaim(new Claim(ClaimTypes.Role, configuration.Value.OwnerOrg)); - identity.AddClaim(new Claim(ClaimTypes.Authentication, token)); - identity.AddClaim(new Claim("access_token", token)); - - var principal = new ClaimsPrincipal(identity); + if (rdOrg != null) + { + 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..."); - var issued = DateTime.UtcNow; - var expires = issued.Add(TimeSpan.FromMinutes(50)); - var roles = string.Join(",", identity.Claims.Where(claim => claim.Type == ClaimTypes.Role).Select(claim => claim.Value)); + 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; + HttpContext.User = principal; + Thread.CurrentPrincipal = HttpContext.User; - var jwt = principal.AsJWT(api.Value.SymetricKey, configuration.Value.JwtIssuer, configuration.Value.JwtAudience); - HttpContext.Session.SetString("jwt", jwt); - } + 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); + 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."); + return default; } } } diff --git a/rubberduckvba.Server/Api/Downloads/DownloadsController.cs b/rubberduckvba.Server/Api/Downloads/DownloadsController.cs index 3a69fc1..5efb567 100644 --- a/rubberduckvba.Server/Api/Downloads/DownloadsController.cs +++ b/rubberduckvba.Server/Api/Downloads/DownloadsController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using rubberduckvba.Server.Services; using System.Collections.Immutable; @@ -6,37 +7,55 @@ namespace rubberduckvba.Server.Api.Downloads; -[ApiController] [AllowAnonymous] -public class DownloadsController(IContentCacheService cache, IRubberduckDbService db) : Controller +[EnableCors(CorsPolicies.AllowAll)] +public class DownloadsController : RubberduckApiController { + private readonly CacheService cache; + private readonly IRubberduckDbService db; + + public DownloadsController(CacheService cache, IRubberduckDbService db, ILogger logger) + : base(logger) + { + this.cache = cache; + this.db = db; + } + [HttpGet("downloads")] - public async Task>> GetAvailableDownloadsAsync() + public IActionResult GetAvailableDownloadsAsync() { - var cacheKey = $"{nameof(GetAvailableDownloadsAsync)}"; - if (!cache.TryGetValue(cacheKey, out IEnumerable downloads)) + return GuardInternalAction(() => { - var tags = (await db.GetLatestTagsAsync(RepositoryId.Rubberduck)).ToImmutableArray(); - var main = tags[0]; - var next = tags[1]; + AvailableDownload[] result = []; + if (!cache.TryGetAvailableDownloads(out var cached)) + { + var tags = db.GetLatestTagsAsync(RepositoryId.Rubberduck).GetAwaiter().GetResult().ToImmutableArray(); + var main = tags[0]; + var next = tags[1]; + + var pdfStyleGuide = new AvailableDownload + { + Name = "Rubberduck Style Guide (PDF)", + Title = "Free (pay what you want) PDF download", + Kind = "pdf", + DownloadUrl = "https://ko-fi.com/s/d91bfd610c" + }; - var pdfStyleGuide = new AvailableDownload + result = + [ + AvailableDownload.FromTag(main), + AvailableDownload.FromTag(next), + pdfStyleGuide + ]; + + cache.Invalidate(result); + } + else { - Name = "Rubberduck Style Guide (PDF)", - Title = "Free (pay what you want) PDF download", - Kind = "pdf", - DownloadUrl = "https://ko-fi.com/s/d91bfd610c" - }; - - downloads = [ - AvailableDownload.FromTag(main), - AvailableDownload.FromTag(next), - pdfStyleGuide - ]; - - cache.SetValue(cacheKey, downloads); - } - - return downloads.Any() ? Ok(downloads) : NoContent(); + result = cached ?? []; + } + + return result.Any() ? Ok(result) : NoContent(); + }); } } diff --git a/rubberduckvba.Server/Api/Features/FeatureViewModel.cs b/rubberduckvba.Server/Api/Features/FeatureViewModel.cs index e852b2e..a47439d 100644 --- a/rubberduckvba.Server/Api/Features/FeatureViewModel.cs +++ b/rubberduckvba.Server/Api/Features/FeatureViewModel.cs @@ -55,7 +55,7 @@ public FeatureViewModel(Feature model, bool summaryOnly = false) public class InspectionViewModel { - public InspectionViewModel(Inspection model, IDictionary tagsByAssetId) + public InspectionViewModel(Inspection model, IEnumerable quickFixes, IDictionary tagsByAssetId) { Id = model.Id; DateTimeInserted = model.DateTimeInserted; @@ -78,7 +78,7 @@ public InspectionViewModel(Inspection model, IDictionary tagsByAssetId InspectionType = model.InspectionType; DefaultSeverity = model.DefaultSeverity; - QuickFixes = model.QuickFixes; + QuickFixes = quickFixes.Where(e => model.QuickFixes.Any(name => string.Equals(e.Name, name, StringComparison.InvariantCultureIgnoreCase))).ToArray(); Reasoning = model.Reasoning; HostApp = model.HostApp; @@ -110,7 +110,7 @@ public InspectionViewModel(Inspection model, IDictionary tagsByAssetId public string? Remarks { get; init; } public string? HostApp { get; init; } public string[] References { get; init; } = []; - public string[] QuickFixes { get; init; } = []; + public QuickFixViewModel[] QuickFixes { get; init; } = []; public InspectionExample[] Examples { get; init; } = []; } @@ -239,11 +239,11 @@ public record class QuickFixInspectionLinkViewModel public class InspectionsFeatureViewModel : FeatureViewModel { - public InspectionsFeatureViewModel(FeatureGraph model, IDictionary tagsByAssetId, bool summaryOnly = false) + public InspectionsFeatureViewModel(FeatureGraph model, IEnumerable quickFixes, IDictionary tagsByAssetId, bool summaryOnly = false) : base(model, summaryOnly) { - Inspections = model.Inspections.OrderBy(e => e.Name).Select(e => new InspectionViewModel(e, tagsByAssetId)).ToArray(); + Inspections = model.Inspections.OrderBy(e => e.Name).Select(e => new InspectionViewModel(e, quickFixes, tagsByAssetId)).ToArray(); } public InspectionViewModel[] Inspections { get; init; } = []; diff --git a/rubberduckvba.Server/Api/Features/FeaturesController.cs b/rubberduckvba.Server/Api/Features/FeaturesController.cs index e384df5..6868419 100644 --- a/rubberduckvba.Server/Api/Features/FeaturesController.cs +++ b/rubberduckvba.Server/Api/Features/FeaturesController.cs @@ -1,29 +1,35 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using rubberduckvba.Server.Data; using rubberduckvba.Server.Model; using rubberduckvba.Server.Model.Entity; using rubberduckvba.Server.Services; using rubberduckvba.Server.Services.rubberduckdb; -using System.ComponentModel; -using System.Reflection; namespace rubberduckvba.Server.Api.Features; -public record class MarkdownFormattingRequestViewModel -{ - public string MarkdownContent { get; init; } - public bool WithVbeCodeBlocks { get; init; } -} - -[ApiController] [AllowAnonymous] -public class FeaturesController(IRubberduckDbService db, FeatureServices features, - IMarkdownFormattingService md, ICacheService cache, - IRepository assetsRepository, - IRepository tagsRepository) : ControllerBase +public class FeaturesController : RubberduckApiController { + private readonly CacheService cache; + private readonly IRubberduckDbService db; + private readonly FeatureServices features; + private readonly IRepository assetsRepository; + private readonly IRepository tagsRepository; + + public FeaturesController(CacheService cache, IRubberduckDbService db, FeatureServices features, + IRepository assetsRepository, IRepository tagsRepository, ILogger logger) + : base(logger) + { + this.cache = cache; + this.db = db; + this.features = features; + this.assetsRepository = assetsRepository; + this.tagsRepository = tagsRepository; + } + private static RepositoryOptionViewModel[] RepositoryOptions { get; } = Enum.GetValues().Select(e => new RepositoryOptionViewModel { Id = e, Name = e.ToString() }).ToArray(); @@ -31,118 +37,121 @@ private async Task GetFeatureOptions(RepositoryId repo await db.GetTopLevelFeatures(repositoryId) .ContinueWith(t => t.Result.Select(e => new FeatureOptionViewModel { Id = e.Id, Name = e.Name, Title = e.Title }).ToArray()); - private bool TryGetCachedContent(out string key, out object cached) - { - key = HttpContext.Request.Path; - return cache.TryGet(key, out cached); - } - [HttpGet("features")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task Index() + public IActionResult Index() { - //if (TryGetCachedContent(out var key, out var cached)) - //{ - // return Ok(cached); - //} - - var features = await db.GetTopLevelFeatures(RepositoryId.Rubberduck); - if (!features.Any()) + return GuardInternalAction(() => { - return NoContent(); - } - - var model = features.Select(e => new FeatureViewModel(e, summaryOnly: true)); - //cache.Write(key, model); - - return Ok(model); + FeatureViewModel[]? result = []; + if (!cache.TryGetFeatures(out result)) + { + var features = db.GetTopLevelFeatures(RepositoryId.Rubberduck).GetAwaiter().GetResult(); + if (!features.Any()) + { + return NoContent(); + } + + result = features + .Select(e => new FeatureViewModel(e, summaryOnly: true)) + .ToArray(); + + if (result.Length > 0) + { + cache.Invalidate(result); + } + } + + return result is not null && result.Length != 0 ? Ok(result) : NoContent(); + }); } - private static readonly IDictionary _moduleTypeNames = typeof(ExampleModuleType).GetMembers().Where(e => e.GetCustomAttribute() != null) - .ToDictionary(member => member.Name, member => member.GetCustomAttribute()?.Description ?? member.Name); - [HttpGet("features/{name}")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task Info([FromRoute] string name) + public IActionResult Info([FromRoute] string name) { - //if (TryGetCachedContent(out var key, out var cached)) - //{ - // return Ok(cached); - //} - - var feature = features.Get(name); - if (feature is null) - { - return NotFound(); - } - - var model = feature is not FeatureGraph graph ? new FeatureViewModel(feature) : feature.Name switch + return GuardInternalAction(() => { - "Inspections" => new InspectionsFeatureViewModel(graph, - graph.Inspections.Select(e => e.TagAssetId).Distinct() - .ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId)))) - , - "QuickFixes" => new QuickFixesFeatureViewModel(graph, - graph.QuickFixes.Select(e => e.TagAssetId).Distinct() - .ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId))), - features.Get("Inspections").Inspections.ToDictionary(inspection => inspection.Name)) - , - "Annotations" => new AnnotationsFeatureViewModel(graph, - graph.Annotations.Select(e => e.TagAssetId).Distinct() - .ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId)))) - , - _ => new FeatureViewModel(feature) - }; - //cache.Write(key, model); - - return Ok(model); + return name.ToLowerInvariant() switch + { + "inspections" => Ok(GetInspections()), + "quickfixes" => Ok(GetQuickFixes()), + "annotations" => Ok(GetAnnotations()), + _ => Ok(GetFeature(name)) + }; + }); } [HttpGet("inspections/{name}")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task Inspection([FromRoute] string name) + public IActionResult Inspection([FromRoute] string name) { - var inspection = features.GetInspection(name); - var tagsByAssetId = new[] { inspection.TagAssetId }.ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId))); - var vm = new InspectionViewModel(inspection, tagsByAssetId); - - return Ok(vm); + return GuardInternalAction(() => + { + InspectionViewModel? result; + if (!cache.TryGetInspection(name, out result)) + { + _ = GetInspections(); // caches all inspections + } + + if (!cache.TryGetInspection(name, out result)) + { + return NotFound(); + } + + return Ok(result); + }); } [HttpGet("annotations/{name}")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task Annotation([FromRoute] string name) + public IActionResult Annotation([FromRoute] string name) { - var annotation = features.GetAnnotation(name); - var tagsByAssetId = new[] { annotation.TagAssetId }.ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId))); - var vm = new AnnotationViewModel(annotation, tagsByAssetId); - - return Ok(vm); + return GuardInternalAction(() => + { + AnnotationViewModel? result; + if (!cache.TryGetAnnotation(name, out result)) + { + _ = GetAnnotations(); // caches all annotations + } + + if (!cache.TryGetAnnotation(name, out result)) + { + return NotFound(); + } + + return Ok(result); + }); } [HttpGet("quickfixes/{name}")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task QuickFix([FromRoute] string name) - { - var quickfix = features.GetQuickFix(name); - var inspectionsByName = features.Get("Inspections").Inspections.ToDictionary(inspection => inspection.Name); - - var tagsByAssetId = new[] { quickfix.TagAssetId }.ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId))); - var vm = new QuickFixViewModel(quickfix, tagsByAssetId, inspectionsByName); - - return Ok(vm); - } - - [HttpGet("features/resolve")] - [AllowAnonymous] - public async Task Resolve([FromQuery] RepositoryId repository, [FromQuery] string name) + public IActionResult QuickFix([FromRoute] string name) { - var graph = await db.ResolveFeature(repository, name); - var markdown = md.FormatMarkdownDocument(graph.Description, withSyntaxHighlighting: true); - return Ok(graph with { Description = markdown }); + return GuardInternalAction(() => + { + QuickFixViewModel? result; + if (!cache.TryGetQuickFix(name, out result)) + { + _ = GetAnnotations(); // caches all quickfixes + } + + if (!cache.TryGetQuickFix(name, out result)) + { + return NotFound(); + } + + return Ok(result); + }); } [HttpGet("features/create")] + [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Create([FromQuery] RepositoryId repository = RepositoryId.Rubberduck, [FromQuery] int? parentId = default) { @@ -154,6 +163,7 @@ public async Task> Create([FromQuery] Reposit } [HttpPost("create")] + [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Create([FromBody] FeatureEditViewModel model) { @@ -176,6 +186,7 @@ public async Task> Create([FromBody] FeatureE } [HttpPost("features/update")] + [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Update([FromBody] FeatureEditViewModel model) { @@ -196,9 +207,72 @@ public async Task> Update([FromBody] FeatureE return new FeatureEditViewModel(result, features, RepositoryOptions); } - [HttpPost("features/markdown")] - public IActionResult FormatMarkdown([FromBody] MarkdownFormattingRequestViewModel model) + private InspectionsFeatureViewModel GetInspections() { - return Ok(md.FormatMarkdownDocument(model.MarkdownContent, model.WithVbeCodeBlocks)); + InspectionsFeatureViewModel result; + if (!cache.TryGetInspections(out result!)) + { + var quickfixesModel = GetQuickFixes(); + + var feature = features.Get("Inspections") as FeatureGraph; + result = new InspectionsFeatureViewModel(feature, quickfixesModel.QuickFixes, + feature.Inspections + .Select(e => e.TagAssetId).Distinct() + .ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId)))); + + cache.Invalidate(result); + } + + return result; } + + private QuickFixesFeatureViewModel GetQuickFixes() + { + QuickFixesFeatureViewModel result; + if (!cache.TryGetQuickFixes(out result!)) + { + var feature = features.Get("QuickFixes") as FeatureGraph; + result = new QuickFixesFeatureViewModel(feature, + feature.QuickFixes + .Select(e => e.TagAssetId).Distinct() + .ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId))), + features.Get("Inspections").Inspections.ToDictionary(inspection => inspection.Name)); + + cache.Invalidate(result); + } + + return result; + } + + private AnnotationsFeatureViewModel GetAnnotations() + { + AnnotationsFeatureViewModel result; + if (!cache.TryGetAnnotations(out result!)) + { + var feature = features.Get("Annotations") as FeatureGraph; + result = new AnnotationsFeatureViewModel(feature, + feature.Annotations + .Select(e => e.TagAssetId).Distinct() + .ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId)))); + + cache.Invalidate(result); + } + + return result; + } + + private FeatureViewModel GetFeature(string name) + { + FeatureViewModel result; + if (!cache.TryGetFeature(name, out result!)) + { + var feature = features.Get(name); + result = new FeatureViewModel(feature); + + cache.Invalidate(result); + } + + return result; + } + } diff --git a/rubberduckvba.Server/Api/Indenter/IndenterController.cs b/rubberduckvba.Server/Api/Indenter/IndenterController.cs new file mode 100644 index 0000000..11a9eed --- /dev/null +++ b/rubberduckvba.Server/Api/Indenter/IndenterController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using RubberduckServices; + +namespace rubberduckvba.Server.Api.Indenter; + +[AllowAnonymous] +[EnableCors(CorsPolicies.AllowAll)] +public class IndenterController : RubberduckApiController +{ + private readonly IIndenterService service; + + public IndenterController(IIndenterService service, ILogger 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); + }); +} diff --git a/rubberduckvba.Server/Api/Tags/TagsController.cs b/rubberduckvba.Server/Api/Tags/TagsController.cs index 2302514..84c0584 100644 --- a/rubberduckvba.Server/Api/Tags/TagsController.cs +++ b/rubberduckvba.Server/Api/Tags/TagsController.cs @@ -1,31 +1,49 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using rubberduckvba.Server.Services; namespace rubberduckvba.Server.Api.Tags; -[ApiController] [AllowAnonymous] -public class TagsController(IRubberduckDbService db) : ControllerBase +[EnableCors(CorsPolicies.AllowAll)] +public class TagsController : RubberduckApiController { + private readonly CacheService cache; + private readonly IRubberduckDbService db; + + public TagsController(CacheService cache, IRubberduckDbService db, ILogger logger) + : base(logger) + { + this.cache = cache; + this.db = db; + } + /// /// Gets information about the latest release tags. /// + [HttpGet("api/v1/public/tags")] // legacy route [HttpGet("tags/latest")] - [AllowAnonymous] - public async Task> Latest() + public IActionResult Latest() { - var model = await db.GetLatestTagsAsync(RepositoryId.Rubberduck); - var main = model.SingleOrDefault(tag => !tag.IsPreRelease); - var next = model.SingleOrDefault(tag => tag.IsPreRelease); - - if (main == default) + return GuardInternalAction(() => { - return NoContent(); - } + LatestTagsViewModel? result; + if (!cache.TryGetLatestTags(out result)) + { + var model = db.GetLatestTagsAsync(RepositoryId.Rubberduck).GetAwaiter().GetResult(); + var main = model.SingleOrDefault(tag => !tag.IsPreRelease); + var next = model.SingleOrDefault(tag => tag.IsPreRelease); + + if (main != default) + { + result = new LatestTagsViewModel(main, next); + cache.Invalidate(result.Value); + } + } - var tags = new LatestTagsViewModel(main, next); - return Ok(tags); + return result is null ? NoContent() : Ok(result); + }); } } diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/SynchronizationPipelineFactory.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/SynchronizationPipelineFactory.cs index 43e03c0..4ef7648 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/SynchronizationPipelineFactory.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/SynchronizationPipelineFactory.cs @@ -54,7 +54,7 @@ public ISynchronizationPipeline Create(TParamete return parameters switch { XmldocSyncRequestParameters => new SynchronizeXmlPipeline(parameters, _logger, _content, _github, _merge, _staging, _markdown, tokenSource, _inspections, _quickfixes, _annotations, _annotationParser, _quickFixParser, _inspectionParser, _tagServices), - TagSyncRequestParameters => new SynchronizeTagsPipeline(parameters, _logger, _content, _github, _merge, _staging, tokenSource), + TagSyncRequestParameters => new SynchronizeTagsPipeline(parameters, _logger, _content, _tagServices, _github, _merge, _staging, tokenSource), _ => throw new NotSupportedException(), }; } diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs index bb6773b..ee71d83 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs @@ -21,7 +21,6 @@ public SyncContext(IRequestParameters parameters) IRequestParameters IPipelineContext.Parameters => Parameters; public void LoadParameters(SyncRequestParameters parameters) { - InvalidContextParameterException.ThrowIfNull(nameof(parameters), parameters); _parameters = parameters; _staging = new StagingContext(parameters); } diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/LoadGitHubTagsBlock.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/LoadGitHubTagsBlock.cs index d9dfc63..9a86535 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/LoadGitHubTagsBlock.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/LoadGitHubTagsBlock.cs @@ -4,7 +4,7 @@ namespace rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncTags; -public class LoadGitHubTagsBlock : TransformBlockBase +public class LoadGitHubTagsBlock : TransformBlockBase { private readonly IGitHubClientService _github; @@ -14,9 +14,9 @@ public LoadGitHubTagsBlock(PipelineSection parent, CancellationToke _github = github; } - public override async Task TransformAsync(SyncRequestParameters input) + public override async Task TransformAsync(SyncContext input) { - var githubTags = await _github.GetAllTagsAsync(); // does not get the assets + var githubTags = await _github.GetAllTagsAsync(); var (gitHubMain, gitHubNext, gitHubOthers) = githubTags.GetLatestTags(); Context.LoadGitHubTags(gitHubMain, gitHubNext, gitHubOthers); diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/SyncTagsSection.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/SyncTagsSection.cs index 143576b..0f1ae3d 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/SyncTagsSection.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/SyncTagsSection.cs @@ -2,84 +2,147 @@ using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.Context; using rubberduckvba.Server.Model; using rubberduckvba.Server.Services; +using rubberduckvba.Server.Services.rubberduckdb; using System.Threading.Tasks.Dataflow; namespace rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncTags; public class SyncTagsSection : PipelineSection { - public SyncTagsSection(IPipeline parent, CancellationTokenSource tokenSource, ILogger logger, IRubberduckDbService content, IGitHubClientService github, IStagingServices staging) + public SyncTagsSection(IPipeline parent, CancellationTokenSource tokenSource, ILogger logger, TagServices tagServices, IGitHubClientService github, IStagingServices staging) : base(parent, tokenSource, logger) { - ReceiveRequest = new ReceiveRequestBlock(this, tokenSource, logger); - BroadcastParameters = new BroadcastParametersBlock(this, tokenSource, logger); - AcquireDbMainTag = new AcquireDbMainTagGraphBlock(this, tokenSource, content, logger); - AcquireDbNextTag = new AcquireDbNextTagGraphBlock(this, tokenSource, content, logger); - JoinDbTags = new DataflowJoinBlock(this, tokenSource, logger, nameof(JoinDbTags)); - LoadDbTags = new LoadDbLatestTagsBlock(this, tokenSource, logger); - LoadGitHubTags = new LoadGitHubTagsBlock(this, tokenSource, github, logger); - JoinTags = new DataflowJoinBlock(this, tokenSource, logger, nameof(JoinTags)); - BroadcastTags = new BroadcastTagsBlock(this, tokenSource, logger); - StreamGitHubTags = new StreamGitHubTagsBlock(this, tokenSource, logger); - GetTagAssets = new GetTagAssetsBlock(this, tokenSource, github, logger); - TagBuffer = new TagBufferBlock(this, tokenSource, logger); - AccumulateProcessedTags = new AccumulateProcessedTagsBlock(this, tokenSource, logger); - SaveTags = new BulkSaveStagingBlock(this, tokenSource, staging, logger); + SynchronizeTags = new SynchronizeTagsBlock(this, tokenSource, logger, tagServices, github); + //ReceiveRequest = new ReceiveRequestBlock(this, tokenSource, logger); + //BroadcastParameters = new BroadcastParametersBlock(this, tokenSource, logger); + //AcquireDbMainTag = new AcquireDbMainTagGraphBlock(this, tokenSource, content, logger); + //AcquireDbNextTag = new AcquireDbNextTagGraphBlock(this, tokenSource, content, logger); + //JoinDbTags = new DataflowJoinBlock(this, tokenSource, logger, nameof(JoinDbTags)); + //LoadDbTags = new LoadDbLatestTagsBlock(this, tokenSource, logger); + //LoadGitHubTags = new LoadGitHubTagsBlock(this, tokenSource, github, logger); + //JoinTags = new DataflowJoinBlock(this, tokenSource, logger, nameof(JoinTags)); + //BroadcastTags = new BroadcastTagsBlock(this, tokenSource, logger); + //StreamGitHubTags = new StreamGitHubTagsBlock(this, tokenSource, logger); + //GetTagAssets = new GetTagAssetsBlock(this, tokenSource, github, logger); + //TagBuffer = new TagBufferBlock(this, tokenSource, logger); + //AccumulateProcessedTags = new AccumulateProcessedTagsBlock(this, tokenSource, logger); + //SaveTags = new BulkSaveStagingBlock(this, tokenSource, staging, logger); } - #region blocks - private ReceiveRequestBlock ReceiveRequest { get; } - private BroadcastParametersBlock BroadcastParameters { get; } - private AcquireDbMainTagGraphBlock AcquireDbMainTag { get; } - private AcquireDbNextTagGraphBlock AcquireDbNextTag { get; } - private DataflowJoinBlock JoinDbTags { get; } - private LoadDbLatestTagsBlock LoadDbTags { get; } - private LoadGitHubTagsBlock LoadGitHubTags { get; } - private DataflowJoinBlock JoinTags { get; } - private BroadcastTagsBlock BroadcastTags { get; } - private StreamGitHubTagsBlock StreamGitHubTags { get; } - private GetTagAssetsBlock GetTagAssets { get; } - private TagBufferBlock TagBuffer { get; } - private AccumulateProcessedTagsBlock AccumulateProcessedTags { get; } - private BulkSaveStagingBlock SaveTags { get; } + //#region blocks + private SynchronizeTagsBlock SynchronizeTags { get; } + //private ReceiveRequestBlock ReceiveRequest { get; } + //private BroadcastParametersBlock BroadcastParameters { get; } + //private AcquireDbMainTagGraphBlock AcquireDbMainTag { get; } + //private AcquireDbNextTagGraphBlock AcquireDbNextTag { get; } + //private DataflowJoinBlock JoinDbTags { get; } + //private LoadDbLatestTagsBlock LoadDbTags { get; } + //private LoadGitHubTagsBlock LoadGitHubTags { get; } + //private DataflowJoinBlock JoinTags { get; } + //private BroadcastTagsBlock BroadcastTags { get; } + //private StreamGitHubTagsBlock StreamGitHubTags { get; } + //private GetTagAssetsBlock GetTagAssets { get; } + //private TagBufferBlock TagBuffer { get; } + //private AccumulateProcessedTagsBlock AccumulateProcessedTags { get; } + //private BulkSaveStagingBlock SaveTags { get; } - public ITargetBlock InputBlock => ReceiveRequest.Block; - public Task OutputTask => SaveTags.Block.Completion; + public ITargetBlock InputBlock => SynchronizeTags.Block!; + public Task OutputTask => SynchronizeTags.Block.Completion; protected override IReadOnlyDictionary Blocks => new Dictionary { - [nameof(ReceiveRequest)] = ReceiveRequest.Block, - [nameof(BroadcastParameters)] = BroadcastParameters.Block, - [nameof(AcquireDbMainTag)] = AcquireDbMainTag.Block, - [nameof(AcquireDbNextTag)] = AcquireDbNextTag.Block, - [nameof(JoinDbTags)] = JoinDbTags.Block, - [nameof(LoadDbTags)] = LoadDbTags.Block, - [nameof(LoadGitHubTags)] = LoadGitHubTags.Block, - [nameof(JoinTags)] = JoinTags.Block, - [nameof(BroadcastTags)] = BroadcastTags.Block, - [nameof(StreamGitHubTags)] = StreamGitHubTags.Block, - [nameof(GetTagAssets)] = GetTagAssets.Block, - [nameof(TagBuffer)] = TagBuffer.Block, - [nameof(AccumulateProcessedTags)] = AccumulateProcessedTags.Block, - [nameof(SaveTags)] = SaveTags.Block, + [nameof(SynchronizeTags)] = SynchronizeTags.Block, + // [nameof(ReceiveRequest)] = ReceiveRequest.Block, + // [nameof(BroadcastParameters)] = BroadcastParameters.Block, + // [nameof(AcquireDbMainTag)] = AcquireDbMainTag.Block, + // [nameof(AcquireDbNextTag)] = AcquireDbNextTag.Block, + // [nameof(JoinDbTags)] = JoinDbTags.Block, + // [nameof(LoadDbTags)] = LoadDbTags.Block, + // [nameof(LoadGitHubTags)] = LoadGitHubTags.Block, + // [nameof(JoinTags)] = JoinTags.Block, + // [nameof(BroadcastTags)] = BroadcastTags.Block, + // [nameof(StreamGitHubTags)] = StreamGitHubTags.Block, + // [nameof(GetTagAssets)] = GetTagAssets.Block, + // [nameof(TagBuffer)] = TagBuffer.Block, + // [nameof(AccumulateProcessedTags)] = AccumulateProcessedTags.Block, + // [nameof(SaveTags)] = SaveTags.Block, }; - #endregion + //#endregion public override void CreateBlocks() { - ReceiveRequest.CreateBlock(); - BroadcastParameters.CreateBlock(ReceiveRequest); - AcquireDbMainTag.CreateBlock(BroadcastParameters); - AcquireDbNextTag.CreateBlock(BroadcastParameters); - JoinDbTags.CreateBlock(AcquireDbMainTag, AcquireDbNextTag); - LoadDbTags.CreateBlock(JoinDbTags); - LoadGitHubTags.CreateBlock(BroadcastParameters); - JoinTags.CreateBlock(LoadDbTags, LoadGitHubTags); - BroadcastTags.CreateBlock(JoinTags); - StreamGitHubTags.CreateBlock(BroadcastTags); - GetTagAssets.CreateBlock(StreamGitHubTags); - TagBuffer.CreateBlock(GetTagAssets); - AccumulateProcessedTags.CreateBlock(TagBuffer); - SaveTags.CreateBlock(AccumulateProcessedTags); + SynchronizeTags.CreateBlock(); + //ReceiveRequest.CreateBlock(); + //BroadcastParameters.CreateBlock(ReceiveRequest); + //AcquireDbMainTag.CreateBlock(BroadcastParameters); + //AcquireDbNextTag.CreateBlock(BroadcastParameters); + //JoinDbTags.CreateBlock(AcquireDbMainTag, AcquireDbNextTag); + //LoadDbTags.CreateBlock(JoinDbTags); + //LoadGitHubTags.CreateBlock(LoadDbTags); + //JoinTags.CreateBlock(LoadDbTags, LoadGitHubTags); + //BroadcastTags.CreateBlock(JoinTags); + //StreamGitHubTags.CreateBlock(BroadcastTags); + //GetTagAssets.CreateBlock(StreamGitHubTags); + //TagBuffer.CreateBlock(GetTagAssets); + //AccumulateProcessedTags.CreateBlock(TagBuffer); + //SaveTags.CreateBlock(AccumulateProcessedTags); } } + +public class SynchronizeTagsBlock : ActionBlockBase +{ + private readonly IGitHubClientService _github; + private readonly TagServices _tagServices; + + public SynchronizeTagsBlock(PipelineSection parent, CancellationTokenSource tokenSource, ILogger logger, + TagServices tagServices, + IGitHubClientService github) + : base(parent, tokenSource, logger) + { + _tagServices = tagServices; + _github = github; + } + + protected override async Task ActionAsync(TagSyncRequestParameters input) + { + var getGithubTags = _github.GetAllTagsAsync(); + var dbMain = _tagServices.GetLatestTag(false); + var dbNext = _tagServices.GetLatestTag(true); + + var githubTags = await getGithubTags; + var (gitHubMain, gitHubNext, _) = githubTags.GetLatestTags(); + + var mergedMain = (dbMain ?? gitHubMain) with { InstallerDownloads = gitHubMain.InstallerDownloads }; + var mergedNext = (dbNext ?? gitHubNext) with { InstallerDownloads = gitHubNext.InstallerDownloads }; + + var inserts = new List(); + var updates = new List(); + + if (dbMain is null) + { + inserts.Add(mergedMain); + } + else + { + updates.Add(mergedMain); + } + + if (dbNext is null) + { + inserts.Add(mergedNext); + } + else + { + updates.Add(mergedNext); + } + + if (inserts.Any()) + { + _tagServices.Create(inserts); + } + if (updates.Any()) + { + _tagServices.Update(updates); + } + } +} \ No newline at end of file diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs index 2bd7421..8da95bd 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs @@ -29,10 +29,10 @@ public SynchronizeXmlDocSection(IPipeline parent, Cancellatio XmlDocInspectionParser xmlInspectionParser) : base(parent, tokenSource, logger) { - Block = new JustFuckingDoEverything(this, tokenSource, logger, content, inspections, quickfixes, annotations, tagServices, github, mergeService, staging, xmlAnnotationParser, xmlQuickFixParser, xmlInspectionParser); + Block = new SynchronizeXmlDocBlock(this, tokenSource, logger, content, inspections, quickfixes, annotations, tagServices, github, mergeService, staging, xmlAnnotationParser, xmlQuickFixParser, xmlInspectionParser); } - public JustFuckingDoEverything Block { get; } + public SynchronizeXmlDocBlock Block { get; } protected override IReadOnlyDictionary Blocks => new Dictionary { @@ -45,7 +45,7 @@ public override void CreateBlocks() } } -public class JustFuckingDoEverything : ActionBlockBase +public class SynchronizeXmlDocBlock : ActionBlockBase { private readonly IRubberduckDbService _content; private readonly IRepository _inspections; @@ -59,7 +59,7 @@ public class JustFuckingDoEverything : ActionBlockBase parent, CancellationTokenSource tokenSource, ILogger logger, + public SynchronizeXmlDocBlock(PipelineSection parent, CancellationTokenSource tokenSource, ILogger logger, IRubberduckDbService content, IRepository inspections, IRepository quickfixes, @@ -90,6 +90,10 @@ protected override async Task ActionAsync(SyncRequestParameters input) { Context.LoadParameters(input); + var dbMain = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: false); + Context.LoadRubberduckDbMain(dbMain); + + var githubTags = await _github.GetAllTagsAsync(); // LoadInspectionDefaultConfig var config = await _github.GetCodeAnalysisDefaultsConfigAsync(); Context.LoadInspectionDefaultConfig(config); @@ -108,10 +112,43 @@ 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 dbNext = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: true); + + var dbTags = _tagServices.GetAllTags().ToDictionary(e => e.Name); + List newTags = []; + if (ghMain.Name != dbMain.Name) + { + if (!dbTags.ContainsKey(ghMain.Name)) + { + newTags.Add(ghMain); + } + else + { + // could be an old tag, ...or the db is just out of date + Logger.LogWarning($"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 + { + // could be an old tag, ...or the db is just out of date + Logger.LogWarning($"Tag metadata mismatch, xmldoc update will not proceed; GitHub@main:{ghNext.Name} ({ghNext.DateCreated}) | rubberduckdb@main: {dbNext.Name} ({dbNext.DateCreated})"); + } + } + + _tagServices.Create(newTags); + + Context.LoadRubberduckDbMain(dbMain); Context.LoadRubberduckDbNext(dbNext); Context.LoadDbTags([dbMain, dbNext]); @@ -334,11 +371,11 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS PrepareStaging = new PrepareStagingBlock(this, tokenSource, logger); SaveStaging = new BulkSaveStagingBlock(this, tokenSource, staging, logger); */ - JustFuckingDoIt = new JustFuckingDoEverything(this, tokenSource, logger, content, inspections, quickfixes, annotations, tagServices, github, mergeService, staging, xmlAnnotationParser, xmlQuickFixParser, xmlInspectionParser); + SynchronizeXmlDoc = new SynchronizeXmlDocBlock(this, tokenSource, logger, content, inspections, quickfixes, annotations, tagServices, github, mergeService, staging, xmlAnnotationParser, xmlQuickFixParser, xmlInspectionParser); } #region blocks - private JustFuckingDoEverything JustFuckingDoIt { get; } + private SynchronizeXmlDocBlock SynchronizeXmlDoc { get; } /* private ReceiveRequestBlock ReceiveRequest { get; } private BroadcastParametersBlock BroadcastParameters { get; } @@ -384,8 +421,8 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS private PrepareStagingBlock PrepareStaging { get; } private BulkSaveStagingBlock SaveStaging { get; } */ - public ITargetBlock InputBlock => JustFuckingDoIt.Block; - public IDataflowBlock OutputBlock => JustFuckingDoIt.Block; + public ITargetBlock InputBlock => SynchronizeXmlDoc.Block; + public IDataflowBlock OutputBlock => SynchronizeXmlDoc.Block; protected override IReadOnlyDictionary Blocks => new Dictionary { @@ -435,7 +472,7 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS [nameof(PrepareStaging)] = PrepareStaging.Block, [nameof(SaveStaging)] = SaveStaging.Block, */ - [nameof(JustFuckingDoIt)] = JustFuckingDoIt.Block, + [nameof(SynchronizeXmlDoc)] = SynchronizeXmlDoc.Block, }; #endregion @@ -487,6 +524,6 @@ public override void CreateBlocks() PrepareStaging.CreateBlock(JoinStagingSources); SaveStaging.CreateBlock(PrepareStaging); */ - JustFuckingDoIt.CreateBlock(); + SynchronizeXmlDoc.CreateBlock(); } } diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeTagsPipeline.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeTagsPipeline.cs index e8ed1ba..4501e83 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeTagsPipeline.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeTagsPipeline.cs @@ -3,6 +3,7 @@ using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncTags; using rubberduckvba.Server.ContentSynchronization.XmlDoc.Abstract; using rubberduckvba.Server.Services; +using rubberduckvba.Server.Services.rubberduckdb; namespace rubberduckvba.Server.ContentSynchronization.Pipeline; @@ -12,14 +13,17 @@ public class SynchronizeTagsPipeline : PipelineBase, ISynchro private readonly IGitHubClientService _github; private readonly IXmlDocMerge _merge; private readonly IStagingServices _staging; + private readonly TagServices _tagServices; - public SynchronizeTagsPipeline(IRequestParameters parameters, ILogger logger, IRubberduckDbService content, IGitHubClientService github, IXmlDocMerge merge, IStagingServices staging, CancellationTokenSource tokenSource) + public SynchronizeTagsPipeline(IRequestParameters parameters, ILogger logger, + IRubberduckDbService content, TagServices tagServices, IGitHubClientService github, IXmlDocMerge merge, IStagingServices staging, CancellationTokenSource tokenSource) : base(new SyncContext(parameters), tokenSource, logger) { _content = content; _github = github; _merge = merge; _staging = staging; + _tagServices = tagServices; } public async Task> ExecuteAsync(SyncRequestParameters parameters, CancellationTokenSource tokenSource) @@ -31,14 +35,14 @@ public async Task> ExecuteAsync(SyncRequestParameters para } // 01. Create the pipeline sections - var synchronizeTags = new SyncTagsSection(this, tokenSource, Logger, _content, _github, _staging); + var synchronizeTags = new SyncTagsSection(this, tokenSource, Logger, _tagServices, _github, _staging); // 02. Wire up the pipeline AddSections(parameters, synchronizeTags); DisposeAfter(synchronizeTags.WhenAllBlocksCompleted); // 03. Light it up - Start(synchronizeTags.InputBlock, parameters); + Start(synchronizeTags.InputBlock, (TagSyncRequestParameters)parameters); // 04. await completion await synchronizeTags.OutputTask; diff --git a/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs b/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs index 4571e31..8c9f85e 100644 --- a/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs +++ b/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs @@ -24,5 +24,4 @@ public record class XmldocSyncRequestParameters : SyncRequestParameters public record class TagSyncRequestParameters : SyncRequestParameters { - public string? Tag { get; init; } } diff --git a/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocAnnotationParser.cs b/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocAnnotationParser.cs index 4939b99..bb80571 100644 --- a/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocAnnotationParser.cs +++ b/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocAnnotationParser.cs @@ -111,6 +111,9 @@ private IEnumerable ParseExamples(XElement node) */ before = example.Elements(XmlDocSchema.Annotation.Example.Before.ElementName) .Select((e, i) => ExtractCodeModule(e.Element(XmlDocSchema.Annotation.Example.Before.Module.ElementName)!, i, "(code pane)")); + + after = example.Elements(XmlDocSchema.Annotation.Example.After.ElementName) + .Select((e, i) => ExtractCodeModule(e.Element(XmlDocSchema.Annotation.Example.Before.Module.ElementName)!, i, "(exported code)")); } if (before.Any() && (after?.Any() ?? false)) diff --git a/rubberduckvba.Server/Data/AnnotationsRepository.cs b/rubberduckvba.Server/Data/AnnotationsRepository.cs index 96374cb..c4a603c 100644 --- a/rubberduckvba.Server/Data/AnnotationsRepository.cs +++ b/rubberduckvba.Server/Data/AnnotationsRepository.cs @@ -61,6 +61,7 @@ INSERT INTO [dbo].[Annotations] ( UPDATE [dbo].[Annotations] SET [DateTimeUpdated]=GETDATE(), [TagAssetId]=@tagAssetId, + [SourceUrl]=@sourceUrl, [Summary]=@summary, [Remarks]=@remarks, [JsonParameters]=@jsonParameters, diff --git a/rubberduckvba.Server/Data/HangfireJobStateRepository.cs b/rubberduckvba.Server/Data/HangfireJobStateRepository.cs new file mode 100644 index 0000000..1f9f99f --- /dev/null +++ b/rubberduckvba.Server/Data/HangfireJobStateRepository.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Options; +using rubberduckvba.Server.Model; + +namespace rubberduckvba.Server.Data; + +public class HangfireJobStateRepository : QueryableRepository +{ + public HangfireJobStateRepository(IOptions settings) + : base(settings) { } + + protected override string SelectSql => "SELECT [JobName],[LastJobId],[CreatedAt],[NextExecution],[StateName],[StateTimestamp] FROM [dbo].[HangfireJobState];"; +} diff --git a/rubberduckvba.Server/Data/InspectionsRepository.cs b/rubberduckvba.Server/Data/InspectionsRepository.cs index c069ec4..ddc3e6a 100644 --- a/rubberduckvba.Server/Data/InspectionsRepository.cs +++ b/rubberduckvba.Server/Data/InspectionsRepository.cs @@ -76,6 +76,7 @@ INSERT INTO [dbo].[Inspections] ( UPDATE [dbo].[Inspections] SET [DateTimeUpdated]=GETDATE(), [TagAssetId]=@tagAssetId, + [SourceUrl]=@sourceUrl, [InspectionType]=@inspectionType, [DefaultSeverity]=@defaultSeverity, [Summary]=@summary, diff --git a/rubberduckvba.Server/Data/QuickFixRepository.cs b/rubberduckvba.Server/Data/QuickFixRepository.cs index 00b148c..b99c08d 100644 --- a/rubberduckvba.Server/Data/QuickFixRepository.cs +++ b/rubberduckvba.Server/Data/QuickFixRepository.cs @@ -73,6 +73,7 @@ INSERT INTO [dbo].[QuickFixes] ( UPDATE [dbo].[QuickFixes] SET [DateTimeUpdated]=GETDATE(), [TagAssetId]=@tagAssetId, + [SourceUrl]=@sourceUrl, [Summary]=@summary, [Remarks]=@remarks, [CanFixMultiple]=@canFixMultiple, diff --git a/rubberduckvba.Server/Data/Repository.cs b/rubberduckvba.Server/Data/Repository.cs index 3a7633f..d2bdbc8 100644 --- a/rubberduckvba.Server/Data/Repository.cs +++ b/rubberduckvba.Server/Data/Repository.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using rubberduckvba.Server.Model.Entity; using System.Data; +using static Dapper.SqlMapper; namespace rubberduckvba.Server.Data; @@ -10,60 +11,72 @@ public interface IRepository where TEntity : Entity { int GetId(string name); TEntity GetById(int id); - IEnumerable GetAll(int? parentId = default); + IEnumerable GetAll(); + IEnumerable GetAll(int? parentId); TEntity Insert(TEntity entity); IEnumerable Insert(IEnumerable entities); void Update(TEntity entity); void Update(IEnumerable entities); } -public abstract class Repository : IRepository where TEntity : Entity +public abstract class QueryableRepository where T : class { - private readonly string _connectionString; - - protected Repository(IOptions settings) + protected QueryableRepository(IOptions settings) { - _connectionString = settings.Value.RubberduckDb ?? throw new InvalidOperationException(); + ConnectionString = settings.Value.RubberduckDb ?? throw new InvalidOperationException(); } + protected string ConnectionString { get; } + protected abstract string SelectSql { get; } + + protected IEnumerable Query(Func> query) { - using var db = new SqlConnection(_connectionString); + using var db = new SqlConnection(ConnectionString); db.Open(); return query(db); } protected T Get(Func query) { - using var db = new SqlConnection(_connectionString); + using var db = new SqlConnection(ConnectionString); db.Open(); return query(db); } + public virtual IEnumerable GetAll() => Query(db => db.Query(SelectSql)); +} + +public abstract class Repository : QueryableRepository, IRepository where TEntity : Entity +{ + protected Repository(IOptions settings) + : base(settings) { } + protected void Execute(Action action) { - using var db = new SqlConnection(_connectionString); + using var db = new SqlConnection(ConnectionString); db.Open(); action(db); } protected abstract string TableName { get; } - protected abstract string SelectSql { get; } + protected virtual string? ParentFKColumnName => null; + protected abstract string InsertSql { get; } protected abstract string UpdateSql { get; } - protected virtual string? ParentFKColumnName => null; - public virtual int GetId(string name) => Get(db => db.QuerySingle($"SELECT [Id] FROM [dbo].[{TableName}] WHERE [Name]=@name", new { name })); - public virtual TEntity GetById(int id) => Get(db => db.QuerySingle(SelectSql + " WHERE a.[Id]=@id", new { id })); - public virtual IEnumerable GetAll(int? parentId = default) => + public virtual IEnumerable GetAll(int? parentId) => ParentFKColumnName is null || !parentId.HasValue - ? Query(db => db.Query(SelectSql)) + ? GetAll() : Query(db => db.Query($"{SelectSql} WHERE a.[{ParentFKColumnName}]=@parentId", new { parentId })); + + public virtual int GetId(string name) => Get(db => db.QuerySingle($"SELECT [Id] FROM [dbo].[{TableName}] WHERE [Name]=@name", new { name })); + public virtual TEntity GetById(int id) => Get(db => db.QuerySingle(SelectSql + " WHERE a.[Id]=@id", new { id })); public virtual TEntity Insert(TEntity entity) => Insert([entity]).Single(); public virtual IEnumerable Insert(IEnumerable entities) { - using var db = new SqlConnection(_connectionString); + using var db = new SqlConnection(ConnectionString); db.Open(); using var transaction = db.BeginTransaction(); @@ -81,7 +94,7 @@ public virtual IEnumerable Insert(IEnumerable entities) public virtual void Update(TEntity entity) => Update([entity]); public virtual void Update(IEnumerable entities) { - using var db = new SqlConnection(_connectionString); + using var db = new SqlConnection(ConnectionString); db.Open(); using var transaction = db.BeginTransaction(); diff --git a/rubberduckvba.Server/GitHubAuthenticationHandler.cs b/rubberduckvba.Server/GitHubAuthenticationHandler.cs index 5e0a85e..51ee239 100644 --- a/rubberduckvba.Server/GitHubAuthenticationHandler.cs +++ b/rubberduckvba.Server/GitHubAuthenticationHandler.cs @@ -20,15 +20,28 @@ public GitHubAuthenticationHandler(IGitHubClientService github, protected async override Task HandleAuthenticateAsync() { - var token = Context.Request.Headers["X-ACCESS-TOKEN"].SingleOrDefault(); - if (token is null) + try { + var token = Context.Request.Headers["X-ACCESS-TOKEN"].SingleOrDefault(); + if (string.IsNullOrWhiteSpace(token)) + { + return AuthenticateResult.Fail("Access token was not provided"); + } + + var principal = await _github.ValidateTokenAsync(token); + if (principal is ClaimsPrincipal) + { + Context.User = principal; + Thread.CurrentPrincipal = principal; + return AuthenticateResult.Success(new AuthenticationTicket(principal, "github")); + } + + return AuthenticateResult.Fail("An invalid access token was provided"); + } + catch (InvalidOperationException e) + { + Logger.LogError(e, e.Message); return AuthenticateResult.NoResult(); } - - var principal = await _github.ValidateTokenAsync(token); - return principal is ClaimsPrincipal - ? AuthenticateResult.Success(new AuthenticationTicket(principal, "github")) - : AuthenticateResult.Fail("Could not validate token"); } } diff --git a/rubberduckvba.Server/GitHubSettings.cs b/rubberduckvba.Server/GitHubSettings.cs index fc941b6..4ca4143 100644 --- a/rubberduckvba.Server/GitHubSettings.cs +++ b/rubberduckvba.Server/GitHubSettings.cs @@ -47,6 +47,9 @@ public record class GitHubSettings public record class HangfireSettings { + public int MaxInitializationAttempts { get; set; } = 5; + public int InitializationRetryDelaySeconds { get; set; } = 10; + public int ServerCheckIntervalMinutes { get; set; } = 15; public int QueuePollIntervalSeconds { get; set; } = 30; public int SchedulePollIntervalSeconds { get; set; } = 30; diff --git a/rubberduckvba.Server/Hangfire/QueuedUpdateOrchestrator.cs b/rubberduckvba.Server/Hangfire/QueuedUpdateOrchestrator.cs index 1e64a03..5ab7dc7 100644 --- a/rubberduckvba.Server/Hangfire/QueuedUpdateOrchestrator.cs +++ b/rubberduckvba.Server/Hangfire/QueuedUpdateOrchestrator.cs @@ -109,6 +109,7 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton, InspectionsRepository>(); services.AddSingleton, QuickFixRepository>(); services.AddSingleton, AnnotationsRepository>(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton, SynchronizationPipelineFactory>(); diff --git a/rubberduckvba.Server/Model/HangfireJobState.cs b/rubberduckvba.Server/Model/HangfireJobState.cs new file mode 100644 index 0000000..b309273 --- /dev/null +++ b/rubberduckvba.Server/Model/HangfireJobState.cs @@ -0,0 +1,11 @@ +namespace rubberduckvba.Server.Model; + +public record class HangfireJobState +{ + public string JobName { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public int? LastJobId { get; set; } + public string? NextExecution { get; set; } + public string? StateName { get; set; } + public string? StateTimestamp { get; set; } +} diff --git a/rubberduckvba.Server/Program.cs b/rubberduckvba.Server/Program.cs index fd06fbe..05feb62 100644 --- a/rubberduckvba.Server/Program.cs +++ b/rubberduckvba.Server/Program.cs @@ -7,6 +7,9 @@ using NLog.Config; using NLog.Extensions.Logging; using NLog.Targets; +using Polly; +using Polly.Retry; +using Rubberduck.SmartIndenter; using RubberduckServices; using rubberduckvba.Server.Api.Admin; using rubberduckvba.Server.ContentSynchronization; @@ -29,6 +32,12 @@ public class HangfireAuthenticationFilter : IDashboardAuthorizationFilter public bool Authorize([NotNull] DashboardContext context) => Debugger.IsAttached || context.Request.RemoteIpAddress == "20.220.30.154"; } +public static class CorsPolicies +{ + public const string AllowAll = "AllowAll"; + public const string AllowAuthenticated = "AllowAuthenticated"; +} + public class Program { public static void Main(string[] args) @@ -42,20 +51,51 @@ public static void Main(string[] args) builder.Services.Configure(options => builder.Configuration.GetSection("Api").Bind(options)); builder.Services.Configure(options => builder.Configuration.GetSection("Hangfire").Bind(options)); + builder.Services.AddCors(builder => + { + builder.AddPolicy(CorsPolicies.AllowAll, policy => + { + policy + .SetIsOriginAllowed(origin => true) + .AllowAnyHeader() + .WithMethods("OPTIONS", "GET", "POST") + .Build(); + }); + builder.AddPolicy(CorsPolicies.AllowAuthenticated, policy => + { + policy + .SetIsOriginAllowed(origin => true) + .WithHeaders("X-ACCESS-TOKEN") + .WithMethods("OPTIONS", "GET", "POST") + .AllowCredentials() + .Build(); + }); + }); builder.Services.AddAuthentication(options => { options.RequireAuthenticatedSignIn = false; + options.DefaultAuthenticateScheme = "github"; + options.AddScheme("github", builder => { builder.HandlerType = typeof(GitHubAuthenticationHandler); }); + options.AddScheme("webhook-signature", builder => + { + builder.HandlerType = typeof(WebhookAuthenticationHandler); + }); }); + builder.Services.AddAuthorization(options => { options.AddPolicy("github", builder => { - builder.RequireAuthenticatedUser(); + builder.RequireAuthenticatedUser().AddAuthenticationSchemes("github"); + }); + options.AddPolicy("webhook", builder => + { + builder.RequireAuthenticatedUser().AddAuthenticationSchemes("webhook-signature"); }); }); @@ -78,18 +118,34 @@ public static void Main(string[] args) app.UseHttpsRedirection(); app.UseRouting(); + app.UseCors(); + + app.UseAuthentication(); app.UseAuthorization(); + app.UseSession(); app.MapControllers(); app.MapFallbackToFile("/index.html"); - app.UseCors(policy => + var logger = app.Services.GetRequiredService>(); + logger.LogInformation("App configuration completed. Starting hangfire..."); + + var hangfireOptions = app.Services.GetService>()?.Value ?? new(); + new ResiliencePipelineBuilder().AddRetry(new RetryStrategyOptions { - policy.SetIsOriginAllowed(origin => true); - }); - app.UseSession(); + Delay = TimeSpan.FromSeconds(30), + MaxRetryAttempts = hangfireOptions.MaxInitializationAttempts, + OnRetry = (context) => + { + var retryCount = context.AttemptNumber; + var delay = context.RetryDelay; + + logger.LogError(context.Outcome.Exception, $"Hangfire failed to start | Retrying storage connection in {delay.TotalSeconds} seconds. Attempt {retryCount} of {hangfireOptions.MaxInitializationAttempts}"); + return ValueTask.CompletedTask; + } + }).Build().Execute(() => StartHangfire(app)); - StartHangfire(app); + logger.LogInformation("Hangfire initialization completed. Starting application..."); app.Run(); } @@ -147,6 +203,12 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -157,8 +219,8 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton, InspectionsRepository>(); services.AddSingleton, QuickFixRepository>(); services.AddSingleton, AnnotationsRepository>(); + services.AddSingleton(); - //services.AddSingleton(); services.AddSingleton(); services.AddSingleton, InstallerDownloadStatsOrchestrator>(); services.AddSingleton, XmldocContentOrchestrator>(); @@ -170,13 +232,8 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); // TODO deprecate + services.AddSingleton(); - services.AddMemoryCache(cache => - { - cache.ExpirationScanFrequency = TimeSpan.FromMinutes(20); - }); services.AddSession(ConfigureSession); } diff --git a/rubberduckvba.Server/RubberduckApiController.cs b/rubberduckvba.Server/RubberduckApiController.cs new file mode 100644 index 0000000..5a59290 --- /dev/null +++ b/rubberduckvba.Server/RubberduckApiController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using rubberduckvba.Server; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace rubberduckvba.Server; + +[ApiController] +[EnableCors("CorsPolicy")] +public abstract class RubberduckApiController : ControllerBase +{ + private readonly ILogger _logger; + + protected RubberduckApiController(ILogger logger) + { + _logger = logger; + } + + protected ILogger Logger => _logger; + + protected IActionResult GuardInternalAction(Func method, [CallerMemberName] string name = default!) + { + var sw = Stopwatch.StartNew(); + IActionResult result = NoContent(); + var success = false; + try + { + _logger.LogTrace("GuardInternalAction:{name} | ▶ Invoking controller action", name); + result = method.Invoke(); + success = true; + } + catch (Exception exception) + { + _logger.LogError(exception, "GuardInternalAction:{name} | ❌ An exception was caught", name); + throw; + } + finally + { + sw.Stop(); + if (success) + { + _logger.LogTrace("GuardInternalAction:{name} | ✔️ Controller action completed | ⏱️ {elapsed}", name, sw.Elapsed); + } + else + { + _logger.LogWarning("GuardInternalAction:{name} | ⚠️ Controller action completed with errors", name); + } + } + + //Response.Headers.AccessControlAllowOrigin = "*"; + return result; + } +} diff --git a/rubberduckvba.Server/Services/CacheService.cs b/rubberduckvba.Server/Services/CacheService.cs new file mode 100644 index 0000000..1e97775 --- /dev/null +++ b/rubberduckvba.Server/Services/CacheService.cs @@ -0,0 +1,180 @@ +using Microsoft.Extensions.Caching.Memory; +using rubberduckvba.Server.Api.Downloads; +using rubberduckvba.Server.Api.Features; +using rubberduckvba.Server.Api.Tags; +using rubberduckvba.Server.Data; +using rubberduckvba.Server.Model; + +namespace rubberduckvba.Server.Services; + +public class CacheService +{ + private readonly MemoryCache _cache; + private readonly MemoryCacheEntryOptions _options; + + private readonly HangfireJobStateRepository _repository; + private readonly ILogger _logger; + + private const string JobStateSucceeded = "Succeeded"; + private const string TagsJobName = "update_installer_downloads"; + private const string XmldocJobName = "update_xmldoc_content"; + + public CacheService(HangfireJobStateRepository repository, ILogger logger) + { + _cache = new MemoryCache(new MemoryCacheOptions()); + _options = new MemoryCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromHours(1), + }; + + _repository = repository; + _logger = logger; + } + + public HangfireJobState? TagsJobState { get; private set; } + public HangfireJobState? XmldocJobState { get; private set; } + + private void GetCurrentJobState() + { + var state = _repository.GetAll().ToDictionary(e => e.JobName); + TagsJobState = state.TryGetValue(TagsJobName, out var tagsJobState) ? tagsJobState : null; + XmldocJobState = state.TryGetValue(XmldocJobName, out var xmldocsJobState) ? xmldocsJobState : null; + } + + public bool TryGetLatestTags(out LatestTagsViewModel? cached) => TryReadFromTagsCache("tags/latest", out cached); + public bool TryGetAvailableDownloads(out AvailableDownload[]? cached) => TryReadFromTagsCache("downloads", out cached); + public bool TryGetFeatures(out FeatureViewModel[]? cached) => TryReadXmldocCache("features", out cached); + public bool TryGetFeature(string name, out FeatureViewModel? cached) => TryReadXmldocCache($"features/{name.ToLowerInvariant()}", out cached); + public bool TryGetInspections(out InspectionsFeatureViewModel? cached) => TryReadXmldocCache($"inspections", out cached); + public bool TryGetInspection(string name, out InspectionViewModel? cached) => TryReadXmldocCache($"inspections/{name.ToLowerInvariant()}", out cached); + public bool TryGetQuickFixes(out QuickFixesFeatureViewModel? cached) => TryReadXmldocCache($"quickfixes", out cached); + public bool TryGetQuickFix(string name, out QuickFixViewModel? cached) => TryReadXmldocCache($"quickfixes/{name.ToLowerInvariant()}", out cached); + public bool TryGetAnnotations(out AnnotationsFeatureViewModel? cached) => TryReadXmldocCache($"annotations", out cached); + public bool TryGetAnnotation(string name, out AnnotationViewModel? cached) => TryReadXmldocCache($"annotations/{name.ToLowerInvariant()}", out cached); + + public void Invalidate(LatestTagsViewModel newContent) + { + GetCurrentJobState(); + if (TagsJobState?.StateName == JobStateSucceeded) + { + Write("tags/latest", newContent); + } + else + { + _logger.LogWarning($"TagsJobState is not '{JobStateSucceeded}' as expected (LastJobId: {TagsJobState?.LastJobId}); content will not be cached"); + } + } + + public void Invalidate(AvailableDownload[] newContent) + { + GetCurrentJobState(); + if (TagsJobState?.StateName == JobStateSucceeded) + { + Write("downloads", newContent); + } + else + { + _logger.LogWarning($"TagsJobState is not '{JobStateSucceeded}' as expected (LastJobId: {TagsJobState?.LastJobId}); content will not be cached"); + } + } + public void Invalidate(FeatureViewModel newContent) + { + GetCurrentJobState(); + if (XmldocJobState?.StateName == JobStateSucceeded) + { + Write($"features/{newContent.Name.ToLowerInvariant()}", newContent); + } + else + { + _logger.LogWarning($"XmldocJobState is not '{JobStateSucceeded}' as expected (LastJobId: {XmldocJobState?.LastJobId}); content will not be cached"); + } + } + public void Invalidate(FeatureViewModel[] newContent) + { + GetCurrentJobState(); + if (XmldocJobState?.StateName == JobStateSucceeded) + { + Write($"features", newContent); + } + else + { + _logger.LogWarning($"XmldocJobState is not '{JobStateSucceeded}' as expected (LastJobId: {XmldocJobState?.LastJobId}); content will not be cached"); + } + } + public void Invalidate(InspectionsFeatureViewModel newContent) + { + GetCurrentJobState(); + if (!TryReadXmldocCache("inspections", out _) || XmldocJobState?.StateName == JobStateSucceeded) + { + Write("inspections", newContent); + foreach (var item in newContent.Inspections) + { + Write($"inspections/{item.Name.ToLowerInvariant()}", item); + } + } + else + { + _logger.LogWarning($"XmldocJobState is not '{JobStateSucceeded}' as expected (LastJobId: {XmldocJobState?.LastJobId}); content will not be cached"); + } + } + public void Invalidate(QuickFixesFeatureViewModel newContent) + { + GetCurrentJobState(); + if (!TryReadXmldocCache("quickfixes", out _) || XmldocJobState?.StateName == JobStateSucceeded) + { + Write("quickfixes", newContent); + foreach (var item in newContent.QuickFixes) + { + Write($"quickfixes/{item.Name.ToLowerInvariant()}", item); + } + } + else + { + _logger.LogWarning($"XmldocJobState is not '{JobStateSucceeded}' as expected (LastJobId: {XmldocJobState?.LastJobId}); content will not be cached"); + } + } + public void Invalidate(AnnotationsFeatureViewModel newContent) + { + GetCurrentJobState(); + if (!TryReadXmldocCache("annotations", out _) || XmldocJobState?.StateName == JobStateSucceeded) + { + Write("annotations", newContent); + foreach (var item in newContent.Annotations) + { + Write($"annotations/{item.Name.ToLowerInvariant()}", item); + } + } + else + { + _logger.LogWarning($"XmldocJobState is not '{JobStateSucceeded}' as expected (LastJobId: {XmldocJobState?.LastJobId}); content will not be cached"); + } + } + + public void Clear() + { + _cache.Clear(); + _logger.LogInformation("Cache was cleared."); + } + + private void Write(string key, T value) + { + _cache.Set(key, value, _options); + _logger.LogInformation("Cache key '{key}' was invalidated.", key); + } + + private bool TryReadFromTagsCache(string key, out T? cached) + { + var result = _cache.TryGetValue(key, out cached); + _logger.LogDebug("TagsCache hit: '{key}' (valid: {result})", key, result); + + return result; + } + + private bool TryReadXmldocCache(string key, out T? cached) + { + var result = _cache.TryGetValue(key, out cached); + _logger.LogDebug("XmldocCache hit: '{key}' (valid: {result})", key, result); + + return result; + } +} \ No newline at end of file diff --git a/rubberduckvba.Server/Services/ContentCacheService.cs b/rubberduckvba.Server/Services/ContentCacheService.cs deleted file mode 100644 index 084e49a..0000000 --- a/rubberduckvba.Server/Services/ContentCacheService.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using System.Collections.Concurrent; -using System.Diagnostics; - -namespace rubberduckvba.Server.Services; - -public interface IContentCacheService : IDisposable -{ - int Count { get; } - void Clear(); - void Invalidate(string key, StringComparison stringComparison = StringComparison.InvariantCultureIgnoreCase); - void Invalidate(Func predicate); - void SetValue(string key, T value); - bool TryGetValue(string key, out T value); -} - -public class ContentCacheService : IContentCacheService -{ - private readonly IMemoryCache _cache; - private readonly ConcurrentDictionary _cacheKeys = new ConcurrentDictionary(); - - private static readonly MemoryCacheEntryOptions _cacheOptions = - new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2), - }; - - public ContentCacheService(IMemoryCache cache) - { - _cache = cache; - } - - public int Count => _cacheKeys.Count; - - public void Clear() - { - foreach (var key in _cacheKeys) - { - _cache.Remove(key); - } - _cacheKeys.Clear(); - Debug.WriteLine("**Cache cleared"); - } - - public void Invalidate(string key, StringComparison stringComparison = StringComparison.InvariantCultureIgnoreCase) => - Invalidate((e) => key.IndexOf(e, stringComparison) >= 0); - - public void Invalidate(Func predicate) - { - var keys = _cacheKeys.Keys.Where(predicate).ToArray(); - foreach (var key in keys) - { - _cache.Remove(key); - _cacheKeys.TryRemove(key, out _); - } - } - - public void SetValue(string key, T value) - { - _cache.Set(key, value, _cacheOptions); - _cacheKeys.TryAdd(key, key); - //Debug.WriteLine($"**Cache WRITE for key: '{key}'"); - } - - public bool TryGetValue(string key, out T value) - { - if (_cache.TryGetValue(key, out value)) - { - //Debug.WriteLine($"**Cache READ for key: '{key}'"); - return true; - } - - //Debug.WriteLine($"**Cache MISS for key: '{key}'"); - _cacheKeys.TryRemove(key, out _); - return false; - } - - public void Dispose() - { - Clear(); - _cache.Dispose(); - //Debug.WriteLine("**Cache disposed"); - } -} diff --git a/rubberduckvba.Server/Services/DistributedCacheService.cs b/rubberduckvba.Server/Services/DistributedCacheService.cs deleted file mode 100644 index ce0e8a3..0000000 --- a/rubberduckvba.Server/Services/DistributedCacheService.cs +++ /dev/null @@ -1,67 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; -using System.Collections.Concurrent; -using System.Text; -using System.Text.Json; - -namespace rubberduckvba.Server.Services; - -public interface ICacheService -{ - bool TryGet(string key, out T value); - void Write(string key, T value); - - void Invalidate(string key); - void Invalidate(); -} - -public class CacheService(IDistributedCache cache) : ICacheService -{ - private static DistributedCacheEntryOptions CacheOptions { get; } = new DistributedCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromHours(1), - }; - - private readonly ConcurrentDictionary _keys = []; - - public void Invalidate() - { - foreach (var key in _keys.Keys) - { - Invalidate(key); - } - } - - public void Invalidate(string key) - { - _keys.TryRemove(key, out _); - cache.Remove(key); - } - - public bool TryGet(string key, out T value) - { - if (!_keys.TryGetValue(key, out _)) - { - value = default!; - return false; - } - - var bytes = cache.Get(key); - if (bytes == null) - { - Invalidate(key); - value = default!; - return false; - } - - _keys[key] = TimeProvider.System.GetUtcNow().DateTime; - value = JsonSerializer.Deserialize(bytes)!; - return value != null; - } - - public void Write(string key, T value) - { - var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value)); - cache.Set(key, bytes, CacheOptions); - _keys[key] = TimeProvider.System.GetUtcNow().DateTime; - } -} diff --git a/rubberduckvba.Server/Services/GitHubClientService.cs b/rubberduckvba.Server/Services/GitHubClientService.cs index 80e6f49..76a1b7d 100644 --- a/rubberduckvba.Server/Services/GitHubClientService.cs +++ b/rubberduckvba.Server/Services/GitHubClientService.cs @@ -6,6 +6,7 @@ using rubberduckvba.Server.ContentSynchronization.XmlDoc.Schema; using rubberduckvba.Server.Model; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Text; using System.Web; @@ -23,6 +24,13 @@ public interface IGitHubClientService public class GitHubClientService(IOptions configuration, ILogger logger) : IGitHubClientService { + private class ReleaseComparer : IEqualityComparer + { + public bool Equals(Release? x, Release? y) => x?.Name == y?.Name; + + public int GetHashCode([DisallowNull] Release obj) => HashCode.Combine(obj.Name); + } + public async Task ValidateTokenAsync(string? token) { if (token is null) @@ -44,11 +52,11 @@ public class GitHubClientService(IOptions configuration, ILogger var user = await client.User.Current(); var identity = new ClaimsIdentity(new[] { - new Claim(ClaimTypes.Name, user.Name), - new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.Login), new Claim(ClaimTypes.Role, config.OwnerOrg), + new Claim(ClaimTypes.Authentication, token), new Claim("access_token", token) - }); + }, "github"); return new ClaimsPrincipal(identity); } @@ -58,7 +66,12 @@ public async Task> GetAllTagsAsync() var credentials = new Credentials(config.OrgToken); var client = new GitHubClient(new ProductHeaderValue(config.UserAgent), new InMemoryCredentialStore(credentials)); - var releases = await client.Repository.Release.GetAll(config.OwnerOrg, config.Rubberduck, new ApiOptions { PageCount = 1, PageSize = 10 }); + + var getReleases = client.Repository.Release.GetAll(config.OwnerOrg, config.Rubberduck, new ApiOptions { PageCount = 1, PageSize = 10 }); + var getLatest = client.Repository.Release.GetLatest(config.OwnerOrg, config.Rubberduck); + await Task.WhenAll(getReleases, getLatest); + + var releases = (await getReleases).Append(await getLatest).ToHashSet(new ReleaseComparer()); return (from release in releases let installer = release.Assets.SingleOrDefault(asset => asset.Name.EndsWith(".exe") && asset.Name.StartsWith("Rubberduck.Setup")) diff --git a/rubberduckvba.Server/Services/RubberduckDbService.cs b/rubberduckvba.Server/Services/RubberduckDbService.cs index 28ba3e9..95a88ad 100644 --- a/rubberduckvba.Server/Services/RubberduckDbService.cs +++ b/rubberduckvba.Server/Services/RubberduckDbService.cs @@ -1,6 +1,7 @@ using Microsoft.Data.SqlClient; using Microsoft.Extensions.Options; using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.Context; +using rubberduckvba.Server.Data; using rubberduckvba.Server.Model; using rubberduckvba.Server.Services.rubberduckdb; @@ -57,6 +58,8 @@ public enum RepositoryId public interface IRubberduckDbService { + Task> GetJobStateAsync(); + Task> GetLatestTagsAsync(RepositoryId repositoryId); Task GetLatestTagAsync(RepositoryId repositoryId, bool includePreRelease); Task UpdateAsync(IEnumerable tags); @@ -73,15 +76,17 @@ public class RubberduckDbService : IRubberduckDbService private readonly string _connectionString; private readonly TagServices _tagServices; private readonly FeatureServices _featureServices; + private readonly HangfireJobStateRepository _hangfireJobState; public RubberduckDbService(IOptions settings, ILogger logger, - TagServices tagServices, FeatureServices featureServices) + TagServices tagServices, FeatureServices featureServices, HangfireJobStateRepository hangfireJobState) { _connectionString = settings.Value.RubberduckDb ?? throw new InvalidOperationException("ConnectionString 'RubberduckDb' could not be retrieved."); Logger = logger; _tagServices = tagServices; _featureServices = featureServices; + _hangfireJobState = hangfireJobState; } private ILogger Logger { get; } @@ -326,4 +331,7 @@ public async Task UpdateAsync(IEnumerable tags) public async Task GetFeatureId(RepositoryId repositoryId, string name) => await Task.Run(() => _featureServices.GetId(name)); -} \ No newline at end of file + + public async Task> GetJobStateAsync() + => await Task.Run(() => _hangfireJobState.GetAll()); +} diff --git a/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs b/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs index 5cec697..b20f524 100644 --- a/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs +++ b/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs @@ -6,69 +6,61 @@ namespace rubberduckvba.Server.Services.rubberduckdb; public class TagServices(IRepository tagsRepository, IRepository tagAssetsRepository) { - private IEnumerable _allAssets = []; - private IEnumerable _allTags = []; - private IEnumerable _latestTags = []; - private TagGraph? _main; - private TagGraph? _next; - private bool _mustInvalidate = true; - - public IEnumerable GetAllTags() + public bool TryGetTag(string name, out Tag tag) { - if (_mustInvalidate || !_allTags.Any()) + var entity = tagsRepository.GetAll().SingleOrDefault(tag => tag.Name == name); + if (entity is null) { - _allTags = tagsRepository.GetAll().ToList(); - _latestTags = _allTags - .GroupBy(tag => tag.IsPreRelease) - .Select(tags => tags.OrderByDescending(tag => tag.DateCreated)) - .SelectMany(tags => tags.Take(1)) - .ToList(); - _mustInvalidate = false; + tag = default!; + return false; } - return _allTags.Select(e => new Tag(e)); + tag = new Tag(entity); + return true; } - public IEnumerable GetLatestTags() + public IEnumerable GetAllTags() { - if (_mustInvalidate || !_latestTags.Any()) - { - _ = GetAllTags(); - } - - return _latestTags.Select(e => new Tag(e)); + return tagsRepository.GetAll().Select(e => new Tag(e)); } - public TagGraph GetLatestTag(bool isPreRelease) + public IEnumerable GetLatestTags() => GetLatestTags(tagsRepository.GetAll().Select(e => new Tag(e))); + + public IEnumerable GetLatestTags(IEnumerable allTags) => allTags + .GroupBy(tag => tag.IsPreRelease) + .Select(tags => tags.OrderByDescending(tag => tag.DateCreated)) + .SelectMany(tags => tags.Take(1)) + .ToList(); + + public TagGraph? GetLatestTag(bool isPreRelease) { - var mustInvalidate = _mustInvalidate; - if (mustInvalidate || !_latestTags.Any()) + var latestTags = GetLatestTags(); + if (!latestTags.Any()) { - _ = GetAllTags(); // _mustInvalidate => false + return null; } - if (!mustInvalidate && !isPreRelease && _main != null) + if (!isPreRelease) { - return _main; + var mainTag = latestTags.First(e => !e.IsPreRelease); + var mainAssets = tagAssetsRepository.GetAll(mainTag.Id); + return new TagGraph(mainTag.ToEntity(), mainAssets); } - if (!mustInvalidate && isPreRelease && _next != null) + else { - return _next; + var nextTag = latestTags.First(e => e.IsPreRelease); + var nextAssets = tagAssetsRepository.GetAll(nextTag.Id); + return new TagGraph(nextTag.ToEntity(), nextAssets); } - - var mainTag = _latestTags.First(e => !e.IsPreRelease); - var mainAssets = tagAssetsRepository.GetAll(mainTag.Id); - _main = new TagGraph(mainTag, mainAssets); - - var nextTag = _latestTags.First(e => e.IsPreRelease); - var nextAssets = tagAssetsRepository.GetAll(nextTag.Id); - _next = new TagGraph(nextTag, nextAssets); - - return isPreRelease ? _next : _main; } public void Create(IEnumerable tags) { + if (!tags.Any()) + { + return; + } + var tagEntities = tagsRepository.Insert(tags.Select(tag => tag.ToEntity())); var tagsByName = tagEntities.ToDictionary( tag => tag.Name, @@ -82,12 +74,15 @@ public void Create(IEnumerable tags) } _ = tagAssetsRepository.Insert(assets); - _mustInvalidate = true; } public void Update(IEnumerable tags) { + if (!tags.Any()) + { + return; + } + tagsRepository.Update(tags.Select(tag => tag.ToEntity())); - _mustInvalidate = true; } } \ No newline at end of file diff --git a/rubberduckvba.Server/WebhookAuthenticationHandler.cs b/rubberduckvba.Server/WebhookAuthenticationHandler.cs new file mode 100644 index 0000000..f409ab0 --- /dev/null +++ b/rubberduckvba.Server/WebhookAuthenticationHandler.cs @@ -0,0 +1,53 @@ + +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text.Encodings.Web; + +namespace rubberduckvba.Server; + +public class WebhookAuthenticationHandler : AuthenticationHandler +{ + private readonly WebhookHeaderValidationService _service; + + public WebhookAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, + WebhookHeaderValidationService service) + : base(options, logger, encoder) + { + _service = service; + } + + protected async override Task HandleAuthenticateAsync() + { + return await Task.Run(() => + { + var userAgent = Context.Request.Headers.UserAgent; + var xGitHubEvent = Context.Request.Headers["X-GitHub-Event"].OfType().ToArray(); + var xGitHubDelivery = Context.Request.Headers["X-GitHub-Delivery"].OfType().ToArray(); + var xHubSignature = Context.Request.Headers["X-Hub-Signature"].OfType().ToArray(); + var xHubSignature256 = Context.Request.Headers["X-Hub-Signature-256"].OfType().ToArray(); + + if (_service.Validate(userAgent, xGitHubEvent, xGitHubDelivery, xHubSignature, xHubSignature256)) + { + var principal = CreatePrincipal(); + var ticket = new AuthenticationTicket(principal, "webhook-signature"); + + return AuthenticateResult.Success(ticket); + } + + return AuthenticateResult.NoResult(); + }); + } + + private static ClaimsPrincipal CreatePrincipal() + { + 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 principal; + } +} \ No newline at end of file diff --git a/rubberduckvba.Server/WebhookHeaderValidationService.cs b/rubberduckvba.Server/WebhookHeaderValidationService.cs new file mode 100644 index 0000000..2d32ecb --- /dev/null +++ b/rubberduckvba.Server/WebhookHeaderValidationService.cs @@ -0,0 +1,91 @@ +using Newtonsoft.Json; +using rubberduckvba.Server.Api.Admin; +using System.Security.Cryptography; +using System.Text; + +namespace rubberduckvba.Server; + +public class WebhookSignatureValidationService(ConfigurationOptions configuration, ILogger logger) +{ + public bool Validate(string payload, string[] xHubSignature256) + { + var signature = xHubSignature256.SingleOrDefault(); + if (signature == default) + { + // SHA-256 signature header must be present + return false; + } + + if (!IsValidSignature(signature, payload)) + { + // SHA-256 signature must match + return false; + } + + return true; + } + + private bool IsValidSignature(string? signature, string payload) + { + if (string.IsNullOrWhiteSpace(signature)) + { + return false; + } + + var secret = configuration.GitHubOptions.Value.WebhookToken; + if (string.IsNullOrWhiteSpace(secret)) + { + logger.LogWarning("Webhook secret was not found; signature will not be validated."); + return false; + } + + var secretBytes = Encoding.UTF8.GetBytes(secret); + var payloadBytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(JsonConvert.DeserializeObject(payload))); + + using var digest = new HMACSHA256(secretBytes); + var hash = digest.ComputeHash(payloadBytes); + + var check = $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}"; + + return (signature == check); + } +} + + +public class WebhookHeaderValidationService(ConfigurationOptions configuration, ILogger logger) +{ + public bool Validate( + string? userAgent, + string[] xGitHubEvent, + string[] xGitHubDelivery, + string[] xHubSignature, + string[] xHubSignature256 + ) + { + if (!(userAgent ?? string.Empty).StartsWith("GitHub-Hookshot/")) + { + // user agent must be GitHub hookshot + return false; + } + + if (!xGitHubEvent.Contains("push")) + { + if (xGitHubEvent.Contains("ping")) + { + // no harm just returning 200-OK on ping + return true; + } + + // only authenticate ping and push events + return false; + } + + if (!Guid.TryParse(xGitHubDelivery.SingleOrDefault(), out _)) + { + // delivery should parse as a GUID + return false; + } + + return true; + } +} diff --git a/rubberduckvba.Server/appsettings.Development.json b/rubberduckvba.Server/appsettings.Development.json new file mode 100644 index 0000000..7959222 --- /dev/null +++ b/rubberduckvba.Server/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "RubberduckDb": "Data Source=(localdb)\\MSSQLLocalDB;Integrated Security=True;Trust Server Certificate=True;", + "HangfireDb": "Data Source=(localdb)\\MSSQLLocalDB;Integrated Security=True;Trust Server Certificate=True;" + }, + "AllowedHosts": "*" +} diff --git a/rubberduckvba.Server/appsettings.json b/rubberduckvba.Server/appsettings.json new file mode 100644 index 0000000..ac1c760 --- /dev/null +++ b/rubberduckvba.Server/appsettings.json @@ -0,0 +1,24 @@ +/* + This file gets created or overwritten upon deployment to either AZ-TEST or AZ-PROD. +*/ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Hangfire": { + "QueuePollIntervalSeconds": 30, + "AutoRetryAttempts": 2, + "AutoRetryDelaySeconds": [ 2, 5, 10, 30, 60 ], + "HeartbeatIntervalMinutes": 1, + "CancellationCheckIntervalSeconds": 300, + "ConfigureHangfireServerOnStartup": true, + "CreateUpdateInstallerDownloadsJob": true, + "CreateUpdateXmldocContentJob": true, + "UpdateInstallerDownloadsSchedule": "0 0 * * *", // daily + "UpdateXmldocContentSchedule": "0 0 31 2 *" // never + }, + "AllowedHosts": "*" +} diff --git a/rubberduckvba.Server/rubberduckvba.Server.csproj b/rubberduckvba.Server/rubberduckvba.Server.csproj index a68f08f..58c4d89 100644 --- a/rubberduckvba.Server/rubberduckvba.Server.csproj +++ b/rubberduckvba.Server/rubberduckvba.Server.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -18,20 +18,21 @@ - - + + 8.*-* - - - - - - - - + + + + + + + + + @@ -41,4 +42,10 @@ + + + ..\RubberduckServices\Libs\Rubberduck.SmartIndenter.dll + + + diff --git a/rubberduckvba.Server/rubberduckvba.Server.http b/rubberduckvba.Server/rubberduckvba.Server.http deleted file mode 100644 index 326553b..0000000 --- a/rubberduckvba.Server/rubberduckvba.Server.http +++ /dev/null @@ -1,6 +0,0 @@ -@rubberduckvba.Server_HostAddress = http://localhost:5033 - -GET {{rubberduckvba.Server_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/rubberduckvba.client/src/app/app.component.html b/rubberduckvba.client/src/app/app.component.html index 36f9caa..ad19940 100644 --- a/rubberduckvba.client/src/app/app.component.html +++ b/rubberduckvba.client/src/app/app.component.html @@ -1,11 +1,17 @@ - -
- -
-
-
+ +
+ +
+
+ +
+
+ +
+
+
© 2014-{{currentYear}} Rubberduck Project Contributors
diff --git a/rubberduckvba.client/src/app/app.component.ts b/rubberduckvba.client/src/app/app.component.ts index 5d8c5a9..58dc64c 100644 --- a/rubberduckvba.client/src/app/app.component.ts +++ b/rubberduckvba.client/src/app/app.component.ts @@ -1,4 +1,3 @@ -import { HttpClient } from '@angular/common/http'; import { Component, OnInit } from '@angular/core'; @Component({ diff --git a/rubberduckvba.client/src/app/app.module.ts b/rubberduckvba.client/src/app/app.module.ts index f32c686..86f5ce9 100644 --- a/rubberduckvba.client/src/app/app.module.ts +++ b/rubberduckvba.client/src/app/app.module.ts @@ -1,16 +1,19 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule, UrlSerializer } from '@angular/router'; + import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { DataService } from './services/data.service'; import { ApiClientService } from './services/api-client.service'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { BrowserModule } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; import { AppComponent } from './app.component'; import { NavMenuComponent } from './components/nav-menu/nav-menu.component'; +import { LoadingContentComponent } from './components/loading-content/loading-content.component'; import { TagDownloadComponent } from './components/tag-download/tag-download.component'; import { DownloadItemComponent } from './components/download-item/download-item.component'; import { FeatureBoxComponent } from './components/feature-box/feature-box.component'; @@ -18,7 +21,6 @@ import { FeatureInfoComponent } from './components/feature-info/feature-info.com import { FeatureItemBoxComponent } from './components/feature-item-box/feature-item-box.component'; import { ExampleBoxComponent } from './components/example-box/example-box.component'; import { FeatureItemExampleComponent } from './components/quickfix-example.modal/quickfix-example.modal.component'; -import { LoadingContentComponent } from './components/loading-content/loading-content.component'; import { InspectionItemBoxComponent } from './components/feature-item-box/inspection-item-box/inspection-item-box.component'; import { AnnotationItemBoxComponent } from './components/feature-item-box/annotation-item-box/annotation-item-box.component'; import { BlogLinkBoxComponent } from './components/blog-link-box/blog-link-box.component'; @@ -31,11 +33,33 @@ import { FeatureComponent } from './routes/feature/feature.component'; import { InspectionComponent } from './routes/inspection/inspection.component'; import { AnnotationComponent } from './routes/annotation/annotation.component'; import { QuickFixComponent } from './routes/quickfixes/quickfix.component'; +import { IndenterComponent } from './routes/indenter/indenter.component'; + +import { DefaultUrlSerializer, UrlTree } from '@angular/router'; +import { AuthComponent } from './routes/auth/auth.component'; +import { AuthMenuComponent } from './components/auth-menu/auth-menu.component'; + +/** + * https://stackoverflow.com/a/39560520 + */ +export class LowerCaseUrlSerializer extends DefaultUrlSerializer { + override parse(url: string): UrlTree { + // Optional Step: Do some stuff with the url if needed. + + // If you lower it in the optional step + // you don't need to use "toLowerCase" + // when you pass it down to the next function + return super.parse(url.toLowerCase()); + } +} @NgModule({ declarations: [ AppComponent, HomeComponent, + AuthComponent, + AuthMenuComponent, + IndenterComponent, FeaturesComponent, FeatureComponent, TagDownloadComponent, @@ -58,18 +82,22 @@ import { QuickFixComponent } from './routes/quickfixes/quickfix.component'; ], bootstrap: [AppComponent], imports: [ - BrowserModule, CommonModule, + BrowserModule, + FormsModule, RouterModule.forRoot([ - { path: '', component: HomeComponent, pathMatch: 'full' }, + // legacy routes: + { path: 'inspections/details/:name', redirectTo: 'inspections/:name' }, + // actual routes: + { path: 'auth/github', component: AuthComponent }, { path: 'features', component: FeaturesComponent }, { path: 'features/:name', component: FeatureComponent }, { path: 'inspections/:name', component: InspectionComponent }, { path: 'annotations/:name', component: AnnotationComponent }, { path: 'quickfixes/:name', component: QuickFixComponent }, - { path: 'about', component: AboutComponent}, - // legacy routes: - { path: 'inspections/Details/:name', redirectTo: 'inspections/:name' } + { path: 'about', component: AboutComponent }, + { path: 'indenter', component: IndenterComponent }, + { path: '', component: HomeComponent, pathMatch: 'full' }, ]), FontAwesomeModule, NgbModule @@ -78,7 +106,11 @@ import { QuickFixComponent } from './routes/quickfixes/quickfix.component'; DataService, ApiClientService, provideHttpClient(withInterceptorsFromDi()), - //httpInterceptorProviders + { + provide: UrlSerializer, + useClass: LowerCaseUrlSerializer + } ] }) + export class AppModule { } diff --git a/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html new file mode 100644 index 0000000..1f6b45b --- /dev/null +++ b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html @@ -0,0 +1,131 @@ + +
+ +
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.ts b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.ts new file mode 100644 index 0000000..0411a94 --- /dev/null +++ b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.ts @@ -0,0 +1,88 @@ +import { Component, OnInit, TemplateRef, ViewChild, inject } from "@angular/core"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { BehaviorSubject } from "rxjs"; +import { UserViewModel } from "../../model/feature.model"; +import { AuthService } from "src/app/services/auth.service"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { fab } from "@fortawesome/free-brands-svg-icons"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { ApiClientService } from "../../services/api-client.service"; + +@Component({ + selector: 'auth-menu', + templateUrl: './auth-menu.component.html' +}) +export class AuthMenuComponent implements OnInit { + + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); + + public set user(value: UserViewModel) { + this._user.next(value); + } + public get user(): UserViewModel { + return this._user.getValue(); + } + + @ViewChild('confirmbox', { read: TemplateRef }) confirmbox: TemplateRef | undefined; + @ViewChild('confirmtagsbox', { read: TemplateRef }) confirmtagsbox: TemplateRef | undefined; + @ViewChild('confirmxmldocsbox', { read: TemplateRef }) confirmxmldocsbox: TemplateRef | undefined; + @ViewChild('confirmclearcachebox', { read: TemplateRef }) confirmclearcachebox: TemplateRef | undefined; + public modal = inject(NgbModal); + + constructor(private auth: AuthService, private api: ApiClientService, private fa: FaIconLibrary) { + fa.addIconPacks(fas); + fa.addIconPacks(fab); + } + + ngOnInit(): void { + this.getUserInfo(); + } + + getUserInfo(): void { + this.auth.getUser().subscribe(result => { + if (result) { + this._user.next(result); + } + }); + } + + public confirm(): void { + this.modal.open(this.confirmbox); + } + + public confirmUpdateTags(): void { + this.modal.open(this.confirmtagsbox); + } + + public confirmUpdateXmldocs(): void { + this.modal.open(this.confirmxmldocsbox); + } + + public confirmClearCache(): void { + this.modal.open(this.confirmclearcachebox); + } + + public signin(): void { + this.auth.signin(); + } + + public signout(): void { + this.auth.signout(); + this.getUserInfo(); + } + + public updateTags(): void { + this.modal.dismissAll(); + this.api.updateTagMetadata().subscribe(jobId => console.log(`UpdateTagMetadata has scheduled job id ${jobId}`)); + } + + public updateXmldocs(): void { + this.modal.dismissAll(); + this.api.updateXmldocMetadata().subscribe(jobId => console.log(`UpdateXmldocMetadata has scheduled job id ${jobId}`)); + } + + public clearCache(): void { + this.modal.dismissAll(); + this.api.clearCache().subscribe(() => console.log(`Cache has been cleared`)); + } +} diff --git a/rubberduckvba.client/src/app/components/example-box/example-box.component.html b/rubberduckvba.client/src/app/components/example-box/example-box.component.html index 02d004d..d4a189c 100644 --- a/rubberduckvba.client/src/app/components/example-box/example-box.component.html +++ b/rubberduckvba.client/src/app/components/example-box/example-box.component.html @@ -1,7 +1,7 @@
- diff --git a/rubberduckvba.client/src/app/components/loading-content/loading-content.component.ts b/rubberduckvba.client/src/app/components/loading-content/loading-content.component.ts index 84d0f17..6e20f5a 100644 --- a/rubberduckvba.client/src/app/components/loading-content/loading-content.component.ts +++ b/rubberduckvba.client/src/app/components/loading-content/loading-content.component.ts @@ -1,11 +1,4 @@ -import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChange, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; -import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; -import { fas } from '@fortawesome/free-solid-svg-icons'; -import { fab } from '@fortawesome/free-brands-svg-icons'; -import { Tag, TagDownloadInfo } from '../../model/tags.model'; -import { BehaviorSubject } from 'rxjs'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { AngularDeviceInformationService } from 'angular-device-information'; +import { Component, Input } from '@angular/core'; @Component({ //standalone: true, @@ -14,4 +7,5 @@ import { AngularDeviceInformationService } from 'angular-device-information'; }) export class LoadingContentComponent { @Input() public show: boolean = true; + @Input() public label: string = 'loading...'; } diff --git a/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.html b/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.html index d74f1c2..c63520a 100644 --- a/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.html +++ b/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.html @@ -1,66 +1,73 @@
- +
+
+
+
+ + Windows desktop only :) + +
+
+ + +
+ + + + +
+
+ + + + + + + + + diff --git a/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.ts b/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.ts index 935e365..b88cba2 100644 --- a/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.ts +++ b/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.ts @@ -1,4 +1,4 @@ -import { Component, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ApiClientService } from '../../services/api-client.service'; import { DownloadInfo } from '../../model/downloads.model'; import { fab } from '@fortawesome/free-brands-svg-icons'; @@ -19,6 +19,7 @@ export class NavMenuComponent implements OnInit { public isHomePage: boolean = true; public isFeaturesPage: boolean = false; public isAboutPage: boolean = false; + public isIndenterPage: boolean = false; public isExpanded: boolean = false; public canDownload: boolean = false; @@ -37,7 +38,8 @@ export class NavMenuComponent implements OnInit { const url = navEnd.urlAfterRedirects; this.isFeaturesPage = url.startsWith('/features'); this.isAboutPage = url.startsWith('/about'); - this.isHomePage = !(this.isFeaturesPage || this.isAboutPage); + this.isIndenterPage = url.startsWith('/indenter'); + this.isHomePage = !(this.isFeaturesPage || this.isAboutPage || this.isIndenterPage); }); } diff --git a/rubberduckvba.client/src/app/model/feature.model.ts b/rubberduckvba.client/src/app/model/feature.model.ts index 302a308..97a0534 100644 --- a/rubberduckvba.client/src/app/model/feature.model.ts +++ b/rubberduckvba.client/src/app/model/feature.model.ts @@ -108,7 +108,7 @@ export interface InspectionViewModel extends XmlDocViewModel { hostApp?: string; references: string[]; - quickFixes: string[]; + quickFixes: QuickFixViewModel[]; examples: InspectionExample[]; @@ -130,6 +130,31 @@ export interface AnnotationsFeatureViewModel extends SubFeatureViewModel { export type XmlDocOrFeatureViewModel = SubFeatureViewModel | InspectionsFeatureViewModel | QuickFixesFeatureViewModel | AnnotationsFeatureViewModel; +export interface QuickFixInspectionLinkViewModel { + name: string; + summary: string; + inspectionType: string; + defaultSeverity: string; +} + +export class QuickFixInspectionLinkViewModelClass implements QuickFixInspectionLinkViewModel { + name: string; + summary: string; + inspectionType: string; + defaultSeverity: string; + + constructor(model: QuickFixInspectionLinkViewModel) { + this.name = model.name; + this.summary = model.summary; + this.inspectionType = model.inspectionType; + this.defaultSeverity = model.defaultSeverity; + } + + public get getSeverityIconClass(): string { + return `icon icon-severity-${this.defaultSeverity.toLowerCase()}`; + } +} + export interface QuickFixViewModel extends XmlDocViewModel { summary: string; remarks?: string; @@ -139,7 +164,7 @@ export interface QuickFixViewModel extends XmlDocViewModel { canFixProject: boolean; canFixAll: boolean; - inspections: string[]; + inspections: QuickFixInspectionLinkViewModel[]; examples: QuickFixExample[]; getGitHubViewLink(): string; @@ -255,7 +280,7 @@ export class InspectionViewModelClass extends SubFeatureViewModelClass implement remarks?: string | undefined; hostApp?: string | undefined; references: string[]; - quickFixes: string[]; + quickFixes: QuickFixViewModel[]; examples: InspectionExample[]; tagAssetId: number; tagName: string; @@ -301,7 +326,7 @@ export class QuickFixViewModelClass extends SubFeatureViewModelClass implements canFixModule: boolean; canFixProject: boolean; canFixAll: boolean; - inspections: string[]; + inspections: QuickFixInspectionLinkViewModelClass[]; examples: QuickFixExample[]; tagAssetId: number; tagName: string; @@ -323,7 +348,7 @@ export class QuickFixViewModelClass extends SubFeatureViewModelClass implements this.remarks = model.remarks; this.examples = model.examples; - this.inspections = model.inspections; + this.inspections = model.inspections.map(e => new QuickFixInspectionLinkViewModelClass(e)); this.tagAssetId = model.tagAssetId; this.tagName = model.tagName; @@ -399,3 +424,9 @@ export class AnnotationsFeatureViewModelClass extends SubFeatureViewModelClass i this.annotations = model.annotations.map(e => new AnnotationViewModelClass(e)); } } + +export interface UserViewModel { + name: string; + isAuthenticated: boolean; + isAdmin: boolean; +} diff --git a/rubberduckvba.client/src/app/model/indenter.model.ts b/rubberduckvba.client/src/app/model/indenter.model.ts new file mode 100644 index 0000000..ec06a38 --- /dev/null +++ b/rubberduckvba.client/src/app/model/indenter.model.ts @@ -0,0 +1,122 @@ +export enum IndenterEmptyLineHandling { + ignore = 0, + remove = 1, + indent = 2 +} + +export enum IndenterEndOfLineCommentStyle { + absolute = 0, + sameGap = 1, + standardGap = 2, + alignInColumn = 3 +} + + +export interface IndenterViewModel { + indenterVersion: string; + code: string; + indentedCode: string; + + // indent + indentSpaces: number; + emptyLineHandlingMethod: IndenterEmptyLineHandling; + indentEntireProcedureBody: boolean; + indentFirstDeclarationBlock: boolean; + indentFirstCommentBlock: boolean; + ignoreEmptyLinesInFirstBlock: boolean; + indentEnumTypeAsProcedure: boolean; + indentCase: boolean; + + // outdent + //indentCompilerDirectives: boolean; + forceCompilerDirectivesInColumn1: boolean; + forceDebugPrintInColumn1: boolean; + forceDebugAssertInColumn1: boolean; + forceDebugStatementsInColumn1: boolean; + forceStopInColumn1: boolean; + + // alignment + alignContinuations: boolean; + ignoreOperatorsInContinuations: boolean; + alignDims: boolean; + alignDimColumn: number; + + + // comments + alignCommentsWithCode: boolean; + endOfLineCommentStyle: IndenterEndOfLineCommentStyle; + endOfLineCommentColumnSpaceAlignment: number; + + // vertical spacing + groupRelatedProperties: boolean; + verticallySpaceProcedures: boolean; + linesBetweenProcedures: number; +} + +export class IndenterVersionViewModelClass { + public version: string; + constructor(version: string){ + this.version = version; + } +} + +export class IndenterViewModelClass implements IndenterViewModel { + indenterVersion: string; + code: string; + indentedCode: string; + indentSpaces: number; + emptyLineHandlingMethod: IndenterEmptyLineHandling; + indentEntireProcedureBody: boolean; + indentFirstDeclarationBlock: boolean; + indentFirstCommentBlock: boolean; + ignoreEmptyLinesInFirstBlock: boolean; + indentEnumTypeAsProcedure: boolean; + indentCase: boolean; + //indentCompilerDirectives: boolean; + forceCompilerDirectivesInColumn1: boolean; + forceStopInColumn1: boolean; + forceDebugPrintInColumn1: boolean; + forceDebugAssertInColumn1: boolean; + forceDebugStatementsInColumn1: boolean; + alignContinuations: boolean; + ignoreOperatorsInContinuations: boolean; + alignDims: boolean; + alignDimColumn: number; + alignCommentsWithCode: boolean; + endOfLineCommentStyle: IndenterEndOfLineCommentStyle; + endOfLineCommentColumnSpaceAlignment: number; + groupRelatedProperties: boolean; + verticallySpaceProcedures: boolean; + linesBetweenProcedures: number; + + constructor(model: IndenterViewModel) { + this.indenterVersion = model.indenterVersion; + this.code = model.code; + this.indentedCode = model.indentedCode ?? model.code; + + this.indentSpaces = model.indentSpaces; + this.emptyLineHandlingMethod = model.emptyLineHandlingMethod; + this.indentEntireProcedureBody = model.indentEntireProcedureBody; + this.indentFirstDeclarationBlock = model.indentFirstDeclarationBlock; + this.indentFirstCommentBlock = model.indentFirstCommentBlock; + this.ignoreEmptyLinesInFirstBlock = model.ignoreEmptyLinesInFirstBlock; + this.indentEnumTypeAsProcedure = model.indentEnumTypeAsProcedure; + this.indentCase = model.indentCase; + //this.indentCompilerDirectives = model.indentCompilerDirectives; + this.forceCompilerDirectivesInColumn1 = model.forceCompilerDirectivesInColumn1; + this.forceStopInColumn1 = model.forceStopInColumn1; + this.forceDebugPrintInColumn1 = model.forceDebugPrintInColumn1; + this.forceDebugAssertInColumn1 = model.forceDebugAssertInColumn1; + this.forceDebugStatementsInColumn1 = model.forceDebugStatementsInColumn1; + this.alignContinuations = model.alignContinuations; + this.ignoreOperatorsInContinuations = model.ignoreOperatorsInContinuations; + this.alignDims = model.alignDims; + this.alignDimColumn = model.alignDimColumn; + this.alignCommentsWithCode = model.alignCommentsWithCode; + this.endOfLineCommentStyle = model.endOfLineCommentStyle; + this.endOfLineCommentColumnSpaceAlignment = model.endOfLineCommentColumnSpaceAlignment; + this.groupRelatedProperties = model.groupRelatedProperties; + this.verticallySpaceProcedures = model.verticallySpaceProcedures; + this.linesBetweenProcedures = model.linesBetweenProcedures; + } +} diff --git a/rubberduckvba.client/src/app/routes/about/about.component.html b/rubberduckvba.client/src/app/routes/about/about.component.html index 78497c0..905baf7 100644 --- a/rubberduckvba.client/src/app/routes/about/about.component.html +++ b/rubberduckvba.client/src/app/routes/about/about.component.html @@ -56,7 +56,7 @@

Privacy Policy


-

Attributions

+

Attributions

Rubberduck was made possible with the combined knowledge of many people, and depends on several libraries and projects.

@@ -131,7 +131,7 @@

Attributions

  • -  Smart Indenter +  Smart Indenter

    The original Smart Indenter VBIDE add-in VB6 source code that served as a reference.

    diff --git a/rubberduckvba.client/src/app/routes/auth/auth.component.html b/rubberduckvba.client/src/app/routes/auth/auth.component.html new file mode 100644 index 0000000..518cc7a --- /dev/null +++ b/rubberduckvba.client/src/app/routes/auth/auth.component.html @@ -0,0 +1 @@ + diff --git a/rubberduckvba.client/src/app/routes/auth/auth.component.ts b/rubberduckvba.client/src/app/routes/auth/auth.component.ts new file mode 100644 index 0000000..a0780bd --- /dev/null +++ b/rubberduckvba.client/src/app/routes/auth/auth.component.ts @@ -0,0 +1,16 @@ +import { Component, OnInit } from "@angular/core"; +import { AuthService } from "src/app/services/auth.service"; + +@Component({ + selector: 'app-auth', + templateUrl: './auth.component.html', +}) +export class AuthComponent implements OnInit { + + constructor(private service: AuthService) { + } + + ngOnInit(): void { + this.service.onGithubCallback(); + } +} diff --git a/rubberduckvba.client/src/app/routes/home/home.component.html b/rubberduckvba.client/src/app/routes/home/home.component.html index 7a46ac3..fb5610e 100644 --- a/rubberduckvba.client/src/app/routes/home/home.component.html +++ b/rubberduckvba.client/src/app/routes/home/home.component.html @@ -161,4 +161,3 @@

    Resources

    - diff --git a/rubberduckvba.client/src/app/routes/indenter/indenter.component.css b/rubberduckvba.client/src/app/routes/indenter/indenter.component.css new file mode 100644 index 0000000..e69de29 diff --git a/rubberduckvba.client/src/app/routes/indenter/indenter.component.html b/rubberduckvba.client/src/app/routes/indenter/indenter.component.html new file mode 100644 index 0000000..d2ec415 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/indenter/indenter.component.html @@ -0,0 +1,472 @@ +
    +
    +

    Online Indenter

    +

    + Consistent identation is one of the most fundemntal qualities of code that is clean and clear. + Rubberduck makes indenting a procedure, module, or even an entire project, quick and simple. +

    +

    + With all the functionalities of the legendary 32-bit Smart Indenter add-in, and then some, + Rubberduck lets you configure many indenting options: feel free to use this page to experiment with them. +

    +
    +
    + +
    +
    + + +
    +
    + +
    +
    +
    + + + + +
    + The size (in spaces) of an indentation level +
    +
    +
    +
    + + + + +
    + Specifies whether and how to handle empty lines +
    +
    +
    +
    +
    + +
    + If checked, procedure scopes add an indentation level +
    +
    +
    +
    +
    +
    + + If checked, a block of declarations at the top of a procedure adds an indentation level +
    +
    +
    +
    +
    + + If checked, a block of comments at the top of a procedure adds an indentation level +
    +
    +
    +
    +
    + + If checked, the first block of comments can include empty lines +
    +
    +
    +
    +
    + +
    + If checked, Enum members add an indentation level +
    +
    +
    +
    +
    +
    + +
    + If checked, Case blocks add an indentation level +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    + +
    + Forces precompiler directives into column 1 regardless of current indentation level +
    +
    +
    +
    +
    +
    + +
    + Forces Debug.Print statements into column 1 regardless of current indentation level +
    +
    +
    +
    +
    +
    + +
    + Forces Debug.Assert statements into column 1 regardless of current indentation level +
    +
    +
    +
    +
    +
    + +
    + Forces Stop statements into column 1 regardless of current indentation level +
    +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    + +
    + If checked, comment lines will be made to follow indentation levels +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + +
    + End-of-line comments will begin at that column whenever possible +
    +
    +
    + +
    +
    + + +
    +
    + +
    +
    +
    +
    + +
    + Removes all spaces between Get, Let, and Set accessors of Property members +
    +
    +
    +
    +
    +
    + +
    + Consistently keeps the specified number of empty lines between the members of a module +
    +
    +
    +
    +
    + +
    + The number of empty lines to maintain between procedures +
    +
    +
    +
    +
    + + +
    +
    + +
    +
    +

    Looking for a programmatic approach?

    +
    + Try the API: +
      +
    1. Request (GET) {{apiBaseUrl}}indenter/defaults to get a JSON object for the model (or copy it from below);
    2. +
    3. Provide the code to be indented in the 'Code' property; use \n for newline characters;
    4. +
    5. Post (POST) the JSON object to {{apiBaseUrl}}indenter/indent to get the indented code back as an array of strings.
    6. +
    + +
    + + +
    +
    + +
    + +
    +
    +
    +

    Try it right here!

    + Rubberduck.SmartIndenter.dll version: {{model.indenterVersion}} +
    +
    + + +
    + +
    +
    + +
    +
    +
    +
     Reset to defaults
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    + Rubberduck logo +
    +
    diff --git a/rubberduckvba.client/src/app/routes/indenter/indenter.component.ts b/rubberduckvba.client/src/app/routes/indenter/indenter.component.ts new file mode 100644 index 0000000..97c3229 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/indenter/indenter.component.ts @@ -0,0 +1,219 @@ +import { Component, OnInit } from "@angular/core"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { IndenterViewModel, IndenterViewModelClass } from "../../model/indenter.model"; +import { ApiClientService } from "../../services/api-client.service"; +import { environment } from "../../../environments/environment"; + +export interface IndenterOptionGroups { + isExpanded: boolean; + isIndentOptionsExpanded: boolean; + isOutdentOptionsExpanded: boolean; + isAlignmentOptionsExpanded: boolean; + isCommentOptionsExpanded: boolean; + isVerticalOptionsExpanded: boolean; + isApiAboutBoxExpanded: boolean; +} + +@Component({ + selector: 'app-indenter', + templateUrl: './indenter.component.html', +}) +export class IndenterComponent implements OnInit, IndenterOptionGroups { + private _model!: IndenterViewModel; + public wasCopied: boolean = false; + public wasTemplateCopied: boolean = false; + + + constructor(fa: FaIconLibrary, private service: ApiClientService) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + this.load(); + } + + private load(): void { + const localModel = localStorage.getItem('indenter.model'); + const localOptionGroups = localStorage.getItem('indenter.options'); + this.isLocalStorageOK = localModel != null || localOptionGroups != null; + + if (localModel) { + this.model = JSON.parse(localModel); + } + else { + this.getDefaults(); + } + + if (localOptionGroups) { + const optionGroups = JSON.parse(localOptionGroups); + this.isExpanded = optionGroups.isExpanded; + this.isIndentOptionsExpanded = optionGroups.isIndentOptionsExpanded; + this.isOutdentOptionsExpanded = optionGroups.isOutdentOptionsExpanded; + this.isAlignmentOptionsExpanded = optionGroups.isAlignmentOptionsExpanded; + this.isCommentOptionsExpanded = optionGroups.isCommentOptionsExpanded; + this.isVerticalOptionsExpanded = optionGroups.isVerticalOptionsExpanded; + this.isApiAboutBoxExpanded = optionGroups.isApiAboutBoxExpanded; + } + } + + private clearStorage(): void { + localStorage.removeItem('indenter.model'); + localStorage.removeItem('indenter.options'); + } + + public getDefaults(): void { + this.service.getIndenterDefaults().subscribe(model => { + this.model = model; + }); + } + + private _isExpanded: boolean = false; + private _isIndentOptionsExpanded: boolean = true; + private _isOutdentOptionsExpanded: boolean = false; + private _isAlignmentOptionsExpanded: boolean = false; + private _isCommentOptionsExpanded: boolean = false; + private _isVerticalOptionsExpanded: boolean = false; + private _isApiAboutBoxExpanded: boolean = false; + + public isIndenterBusy: boolean = false; + + private _isLocalStorageOK: boolean = false; + public get isLocalStorageOK(): boolean { + return this._isLocalStorageOK; + } + public set isLocalStorageOK(value: boolean) { + this._isLocalStorageOK = value; + if (!value) { + this.clearStorage(); + } + else { + this.saveModel(); + this.saveOptions(); + } + } + + public get model(): IndenterViewModel { + return this._model; + } + + private set model(value: IndenterViewModel) { + this._model = value; + this.invalidateClipboard(); + this.saveModel(); + } + + public get asJson(): string { + const copy = new IndenterViewModelClass(this._model); + copy.indentedCode = ''; + return JSON.stringify(copy); + } + + public get isExpanded(): boolean { + return this._isExpanded; + } + public set isExpanded(value: boolean) { + this._isExpanded = value; + this.saveOptions(); + } + public get isIndentOptionsExpanded(): boolean { + return this._isIndentOptionsExpanded; + } + public set isIndentOptionsExpanded(value: boolean) { + this._isIndentOptionsExpanded = value; + this.saveOptions(); + } + public get isCommentOptionsExpanded(): boolean { + return this._isCommentOptionsExpanded; + } + public set isCommentOptionsExpanded(value: boolean) { + this._isCommentOptionsExpanded = value; + this.saveOptions(); + } + public get isVerticalOptionsExpanded(): boolean { + return this._isVerticalOptionsExpanded; + } + public set isVerticalOptionsExpanded(value: boolean) { + this._isVerticalOptionsExpanded = value; + this.saveOptions(); + } + public get isApiAboutBoxExpanded(): boolean { + return this._isApiAboutBoxExpanded; + } + public set isApiAboutBoxExpanded(value: boolean) { + this._isApiAboutBoxExpanded = value; + this.saveOptions(); + } + public get isOutdentOptionsExpanded(): boolean { + return this._isOutdentOptionsExpanded; + } + public set isOutdentOptionsExpanded(value: boolean) { + this._isOutdentOptionsExpanded = value; + this.saveOptions(); + } + public get isAlignmentOptionsExpanded(): boolean { + return this._isAlignmentOptionsExpanded; + } + public set isAlignmentOptionsExpanded(value: boolean) { + this._isAlignmentOptionsExpanded = value; + this.saveOptions(); + } + + private get asOptionGroups(): IndenterOptionGroups { + return { + isExpanded: this.isExpanded, + isIndentOptionsExpanded: this.isIndentOptionsExpanded, + isAlignmentOptionsExpanded: this.isAlignmentOptionsExpanded, + isApiAboutBoxExpanded: this.isApiAboutBoxExpanded, + isCommentOptionsExpanded: this.isCommentOptionsExpanded, + isOutdentOptionsExpanded: this.isOutdentOptionsExpanded, + isVerticalOptionsExpanded: this.isVerticalOptionsExpanded + }; + } + + private saveModel(): void { + if (this.isLocalStorageOK) { + localStorage.setItem('indenter.model', JSON.stringify(this.model)); + } + } + private saveOptions(): void { + if (this.isLocalStorageOK) { + localStorage.setItem('indenter.options', JSON.stringify(this.asOptionGroups)); + } + } + + public indent(): void { + this.isIndenterBusy = true; + this.service.indent(this.model).subscribe(vm => { + this.model.indentedCode = vm.indentedCode; + this.model.code = vm.indentedCode; + this.isIndenterBusy = false; + + this.invalidateClipboard(); + this.saveModel(); + this.saveOptions(); + }); + } + + public copy(): void { + navigator.clipboard.writeText(this.model.code).then(e => this.wasCopied = true); + } + public copyTemplate(): void { + navigator.clipboard.writeText(this.asJson).then(e => this.wasTemplateCopied = true); + } + + private invalidateClipboard(): void { + this.wasCopied = false; + this.wasTemplateCopied = false; + } + + public get apiBaseUrl(): string { + return environment.apiBaseUrl.replace('https://', ''); + } + + public onModelChanged(code: string): void { + this.model.code = code; + this.invalidateClipboard(); + this.saveModel(); + } +} diff --git a/rubberduckvba.client/src/app/routes/inspection/inspection.component.html b/rubberduckvba.client/src/app/routes/inspection/inspection.component.html index 3ac1018..ec35b07 100644 --- a/rubberduckvba.client/src/app/routes/inspection/inspection.component.html +++ b/rubberduckvba.client/src/app/routes/inspection/inspection.component.html @@ -1,9 +1,22 @@

    HomeFeaturesCodeInspectionsInspections

    -

    {{info.title}}

    -
    +

    {{info.title}}

    +

    +
    +
    +
    Discontinued
    +
    +  {{info.tagName}} +
    +
    +
    +
     New! 
    +
    +  {{info.tagName}} +
    +
     {{info.inspectionType}}
    @@ -17,8 +30,8 @@
    +
    -

    @@ -55,10 +68,10 @@

    This inspection offers the following fixes:

    • - +
      -
       {{fix.replace('QuickFix','')}}
      - +
       {{fix.name}}
      +

      {{fix.summary}}

    • diff --git a/rubberduckvba.client/src/app/routes/inspection/inspection.component.ts b/rubberduckvba.client/src/app/routes/inspection/inspection.component.ts index e6fa44b..796e195 100644 --- a/rubberduckvba.client/src/app/routes/inspection/inspection.component.ts +++ b/rubberduckvba.client/src/app/routes/inspection/inspection.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from "@angular/core"; import { InspectionViewModel } from "../../model/feature.model"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; import { fas } from "@fortawesome/free-solid-svg-icons"; import { BehaviorSubject, switchMap } from "rxjs"; @@ -21,7 +21,7 @@ export class InspectionComponent implements OnInit { return this._info.getValue(); } - constructor(private api: ApiClientService, private fa: FaIconLibrary, private route: ActivatedRoute) { + constructor(private api: ApiClientService, private fa: FaIconLibrary, private route: ActivatedRoute, private router: Router) { fa.addIconPacks(fas); } @@ -31,6 +31,7 @@ export class InspectionComponent implements OnInit { const name = params.get('name')!; return this.api.getInspection(name); })).subscribe(e => { + console.log(e); this.info = e; }); } diff --git a/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.html b/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.html index 20d9493..34b2038 100644 --- a/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.html +++ b/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.html @@ -16,6 +16,26 @@
      Remarks
    +
    +
    +
    +
    +  Inspections +
    +

    This action is offered as quickfix to the following inspections:

    + +
    +
    +
    diff --git a/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.ts b/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.ts index d9fda7f..4239302 100644 --- a/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.ts +++ b/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.ts @@ -3,7 +3,7 @@ import { ActivatedRoute } from "@angular/router"; import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; import { fas } from "@fortawesome/free-solid-svg-icons"; import { BehaviorSubject, switchMap } from "rxjs"; -import { QuickFixViewModel } from "../../model/feature.model"; +import { QuickFixViewModel, QuickFixViewModelClass } from "../../model/feature.model"; import { ApiClientService } from "../../services/api-client.service"; @Component({ @@ -12,12 +12,12 @@ import { ApiClientService } from "../../services/api-client.service"; }) export class QuickFixComponent implements OnInit { - private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); - public set info(value: QuickFixViewModel) { + public set info(value: QuickFixViewModelClass) { this._info.next(value); } - public get info(): QuickFixViewModel { + public get info(): QuickFixViewModelClass { return this._info.getValue(); } @@ -31,7 +31,7 @@ export class QuickFixComponent implements OnInit { const name = params.get('name')!; return this.api.getQuickFix(name); })).subscribe(e => { - this.info = e; + this.info = e; console.log(this.info); }); } diff --git a/rubberduckvba.client/src/app/services/api-client.service.ts b/rubberduckvba.client/src/app/services/api-client.service.ts index e315814..5be402b 100644 --- a/rubberduckvba.client/src/app/services/api-client.service.ts +++ b/rubberduckvba.client/src/app/services/api-client.service.ts @@ -1,10 +1,11 @@ import { Injectable } from "@angular/core"; import { LatestTags, Tag } from "../model/tags.model"; -import { AnnotationViewModel, AnnotationViewModelClass, AnnotationsFeatureViewModel, AnnotationsFeatureViewModelClass, FeatureViewModel, FeatureViewModelClass, InspectionViewModel, InspectionViewModelClass, InspectionsFeatureViewModel, InspectionsFeatureViewModelClass, QuickFixViewModel, QuickFixViewModelClass, QuickFixesFeatureViewModel, QuickFixesFeatureViewModelClass, SubFeatureViewModel, SubFeatureViewModelClass, XmlDocOrFeatureViewModel } from "../model/feature.model"; +import { AnnotationViewModel, AnnotationViewModelClass, AnnotationsFeatureViewModel, AnnotationsFeatureViewModelClass, FeatureViewModel, FeatureViewModelClass, InspectionViewModel, InspectionViewModelClass, InspectionsFeatureViewModel, InspectionsFeatureViewModelClass, QuickFixViewModel, QuickFixViewModelClass, QuickFixesFeatureViewModel, QuickFixesFeatureViewModelClass, SubFeatureViewModel, SubFeatureViewModelClass, UserViewModel, XmlDocOrFeatureViewModel } from "../model/feature.model"; import { DownloadInfo } from "../model/downloads.model"; import { DataService } from "./data.service"; import { environment } from "../../environments/environment.prod"; import { Observable, map } from "rxjs"; +import { IndenterVersionViewModelClass, IndenterViewModel, IndenterViewModelClass } from "../model/indenter.model"; @Injectable() export class ApiClientService { @@ -53,4 +54,32 @@ export class ApiClientService { const url = `${environment.apiBaseUrl}quickfixes/${name}` return this.data.getAsync(url).pipe(map(e => new QuickFixViewModelClass(e))); } + + public updateTagMetadata(): Observable { + const url = `${environment.apiBaseUrl}admin/update/tags`; + return this.data.postAsync(url); + } + + public updateXmldocMetadata(): Observable { + const url = `${environment.apiBaseUrl}admin/update/xmldoc`; + return this.data.postAsync(url); + } + + public clearCache(): Observable { + const url = `${environment.apiBaseUrl}admin/cache/clear`; + return this.data.postAsync(url); + } + + public getIndenterDefaults(): Observable { + const url = `${environment.apiBaseUrl}indenter/defaults`; + return this.data.getAsync(url).pipe(map(model => new IndenterViewModelClass(model))); + } + + public indent(model: IndenterViewModel): Observable { + const url = `${environment.apiBaseUrl}indenter/indent`; + return this.data.postAsync(url, model).pipe(map(lines => { + model.indentedCode = lines.join('\n'); + return model; + })); + } } diff --git a/rubberduckvba.client/src/app/services/auth.service.ts b/rubberduckvba.client/src/app/services/auth.service.ts new file mode 100644 index 0000000..fc0c3da --- /dev/null +++ b/rubberduckvba.client/src/app/services/auth.service.ts @@ -0,0 +1,73 @@ +import { Injectable } from "@angular/core"; +import { Observable, map } from "rxjs"; +import { environment } from "../../environments/environment"; +import { UserViewModel } from "../model/feature.model"; +import { AuthViewModel, DataService } from "./data.service"; + + +@Injectable({ providedIn: 'root' }) +export class AuthService { + private timeout: number = 10000; + constructor(private data: DataService) { } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private redirect(url: string = '/'): void { + console.log(`redirecting: ${url}`); + window.location.href = url; + } + + private writeStorage(key: string, value: string): void { + sessionStorage.setItem(key, value); + while (sessionStorage.getItem(key) != value) { + this.sleep(1000); + } + } + + public getUser(): Observable { + const url = `${environment.apiBaseUrl}auth`; + return this.data.getAsync(url); + } + + public signin(): void { + const vm = AuthViewModel.withRandomState(); + this.writeStorage('xsrf:state', vm.state); + + const url = `${environment.apiBaseUrl}auth/signin`; + this.data.postAsync(url, vm) + .subscribe((result: string) => this.redirect(result)); + } + + public signout(): void { + sessionStorage.clear(); + } + + public onGithubCallback(): void { + const urlParams = new URLSearchParams(location.search); + const code: string = urlParams.get('code')!; + const state: string = urlParams.get('state')!; + + if (state === sessionStorage.getItem('xsrf:state')) { + try { + const vm: AuthViewModel = { state, code }; + const url = `${environment.apiBaseUrl}auth/github`; + + this.data.postAsync(url, vm) + .subscribe(result => { + this.writeStorage('github:access_token', result.token!); + this.redirect(); + }); + } + catch (error) { + console.log(error); + this.redirect(); + } + } + else { + console.log('xsrf:state mismatched!'); + this.redirect(); + } + } +} diff --git a/rubberduckvba.client/src/app/services/data.service.ts b/rubberduckvba.client/src/app/services/data.service.ts index 457ed6e..c66a376 100644 --- a/rubberduckvba.client/src/app/services/data.service.ts +++ b/rubberduckvba.client/src/app/services/data.service.ts @@ -1,7 +1,6 @@ import { HttpClient, HttpHeaders } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { map, timeout, catchError, throwError, Observable } from "rxjs"; -import { environment } from "../../environments/environment"; @Injectable() export class DataService { @@ -12,10 +11,16 @@ export class DataService { } public getAsync(url: string): Observable { - const headers = new HttpHeaders() + let headers = new HttpHeaders() .append('accept', 'application/json'); + const token = sessionStorage.getItem('github:access_token'); + let withCreds = false; + if (token) { + headers = headers.append('X-ACCESS-TOKEN', token); + withCreds = true; + } - return this.http.get(url, { headers }) + return this.http.get(url, { headers, withCredentials: withCreds }) .pipe( map(result => result), timeout(this.timeout), @@ -27,13 +32,21 @@ export class DataService { } public postAsync(url: string, content?: TContent): Observable { - const headers = new HttpHeaders() + let headers = new HttpHeaders() + .append('Access-Control-Allow-Origin', '*') .append('accept', 'application/json') .append('Content-Type', 'application/json; charset=utf-8'); + const token = sessionStorage.getItem('github:access_token'); + let withCreds = false; + if (token) { + headers = headers.append('X-ACCESS-TOKEN', token); + withCreds = true; + } + return (content - ? this.http.post(url, content, { headers }) - : this.http.post(url, { headers })) + ? this.http.post(url, content, { headers, withCredentials: withCreds }) + : this.http.post(url, null, { headers, withCredentials: withCreds })) .pipe( map(result => result), timeout(this.timeout), @@ -42,23 +55,19 @@ export class DataService { } } -@Injectable({ providedIn: 'root' }) -export class AuthService { - constructor(private http: HttpClient) { } - - public signin(): Observable { - const headers = new HttpHeaders() - .append('accept', 'application/json') - .append('Content-Type', 'application/json; charset=utf-8'); +export class AuthViewModel { + state: string; + code?: string; + token?: string; - return this.http.post(`${environment.apiBaseUrl}auth/signin`, undefined, { headers }); + constructor(state: string, code?: string, token?: string) { + this.state = state; + this.code = code; + this.token = token; } - public signout(): Observable { - const headers = new HttpHeaders() - .append('accept', 'application/json') - .append('Content-Type', 'application/json; charset=utf-8'); - - return this.http.post(`${environment.apiBaseUrl}auth/signout`, undefined, { headers }); + public static withRandomState() { + const state = crypto.randomUUID(); + return new AuthViewModel(state); } } diff --git a/rubberduckvba.client/src/environments/environment.test.ts b/rubberduckvba.client/src/environments/environment.test.ts index 26d101a..0b96c60 100644 --- a/rubberduckvba.client/src/environments/environment.test.ts +++ b/rubberduckvba.client/src/environments/environment.test.ts @@ -1,4 +1,4 @@ export const environment = { - production: true, - apiBaseUrl: 'https://test.api.rubberduckvba.com/' + production: false, + apiBaseUrl: 'https://localhost:44314/' }; diff --git a/rubberduckvba.database/Views/HangfireJobState.sql b/rubberduckvba.database/Views/HangfireJobState.sql new file mode 100644 index 0000000..04930d7 --- /dev/null +++ b/rubberduckvba.database/Views/HangfireJobState.sql @@ -0,0 +1,43 @@ +CREATE VIEW [dbo].[HangfireJobState] +AS + +WITH meta_xmldoc AS ( + SELECT [Field], [Value] + FROM [hangfire].[Hash] + WHERE [Key] = 'recurring-job:update_xmldoc_content' +), +meta_tags AS ( + SELECT [Field], [Value] + FROM [hangfire].[Hash] + WHERE [Key] = 'recurring-job:update_installer_downloads' +), +jobState AS ( + SELECT s.[Id], [JobId] = j.[Id], j.[StateName], s.[CreatedAt] + FROM [hangfire].[Job] j + INNER JOIN [hangfire].[State] s ON j.StateId = s.Id AND j.Id = s.JobId + WHERE s.[Name] IN ('Succeeded', 'Failed') +), +pivoted AS ( +SELECT + [JobName] = 'update_installer_downloads' + ,[LastJobId] = src.[Value] + ,[CreatedAt] = (SELECT [Value] FROM meta_tags WHERE [Field]='CreatedAt') + ,[NextExecution] = (SELECT [Value] FROM meta_tags WHERE [Field]='NextExecution') +FROM (SELECT [Value] FROM meta_tags WHERE [Field]='LastJobId') src +UNION ALL +SELECT + [JobName] = 'update_xmldoc_content' + ,[LastJobId] = src.[Value] + ,[CreatedAt] = (SELECT [Value] FROM meta_xmldoc WHERE [Field]='CreatedAt') + ,[NextExecution] = (SELECT [Value] FROM meta_xmldoc WHERE [Field]='NextExecution') +FROM (SELECT [Value] FROM meta_xmldoc WHERE [Field]='LastJobId') src +) +SELECT + p.[JobName] + ,p.[LastJobId] + ,p.[CreatedAt] + ,p.[NextExecution] + ,s.[StateName] + ,[StateTimestamp]=s.[CreatedAt] +FROM pivoted p +LEFT JOIN jobState s ON p.[LastJobId]=s.[JobId]; \ No newline at end of file diff --git a/rubberduckvba.database/rubberduckvba.database.localdb.publish.xml b/rubberduckvba.database/rubberduckvba.database.localdb.publish.xml new file mode 100644 index 0000000..cb2c37b --- /dev/null +++ b/rubberduckvba.database/rubberduckvba.database.localdb.publish.xml @@ -0,0 +1,10 @@ + + + + True + rubberduckdb + rubberduckvba.database.sql + Data Source=(localdb)\MSSQLLocalDB;Integrated Security=True;Persist Security Info=False;Pooling=False;Multiple Active Result Sets=False;Connect Timeout=60;Encrypt=True;Trust Server Certificate=True;Command Timeout=0 + 1 + + \ No newline at end of file diff --git a/rubberduckvba.database/rubberduckvba.database.sqlproj b/rubberduckvba.database/rubberduckvba.database.sqlproj index 6887309..0753b36 100644 --- a/rubberduckvba.database/rubberduckvba.database.sqlproj +++ b/rubberduckvba.database/rubberduckvba.database.sqlproj @@ -58,14 +58,16 @@ + - + + diff --git a/rubberduckvba.sln b/rubberduckvba.sln index ee460c7..0818f14 100644 --- a/rubberduckvba.sln +++ b/rubberduckvba.sln @@ -13,6 +13,7 @@ Project("{00D1A9C2-B5F0-4AF3-8072-F6C62B433612}") = "rubberduckvba.database", "r EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4F5F292F-9F99-47E7-9D88-0DC0D886424F}" ProjectSection(SolutionItems) = preProject + .github\workflows\dotnet-cd-prod.yml = .github\workflows\dotnet-cd-prod.yml .github\workflows\dotnet-cd.yml = .github\workflows\dotnet-cd.yml .github\workflows\dotnet-ci.yml = .github\workflows\dotnet-ci.yml README.md = README.md