From 2d71d9d64e7647cd6cb5ee289582183e3f00e7fc Mon Sep 17 00:00:00 2001 From: Andrew Schlackman <72105194+sei-aschlackman@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:20:22 -0400 Subject: [PATCH] .NET 8 update (#44) - updated all projects to .NET 8 - updated all other dependencies to latest - switch to shared docker build workflow - fix and improve event interceptor - added signalR stateful reconnect from .NET 8 https://learn.microsoft.com/en-us/aspnet/core/signalr/configuration?view=aspnetcore-8.0&tabs=dotnet#configure-stateful-reconnect - added SignalR appsettings section with 2 settings - EnableStatefulReconnect defaults to true - StatefulReconnectBufferSizeBytes defaults to the .NET default of 100000 --- .github/workflows/main.yml | 75 +-- Dockerfile | 16 +- .../Data/Extensions/ModelBuilderExtensions.cs | 4 +- Player.Api.Data/Data/Models/Entry.cs | 60 +++ Player.Api.Data/Data/PlayerContext.cs | 11 +- Player.Api.Data/Data/PlayerContextFactory.cs | 30 ++ Player.Api.Data/Player.Api.Data.csproj | 9 +- .../20180425120959_notifications.Designer.cs | 2 +- .../20180425120959_notifications.cs | 2 +- .../20201203194327_file-table.Designer.cs | 2 +- .../Migrations/20201203194327_file-table.cs | 2 +- .../20201208170359_add-team-ids.Designer.cs | 2 +- .../Migrations/20201208170359_add-team-ids.cs | 2 +- .../Migrations/PlayerContextModelSnapshot.cs | 40 +- .../Player.Api.Migrations.PostgreSQL.csproj | 10 +- Player.Api/.config/dotnet-tools.json | 6 +- Player.Api/Controllers/FileController.cs | 5 +- .../DbInterceptors/EventInterceptor.cs | 276 +++++++++++ .../EventTransactionInterceptor.cs | 153 ------ .../Extensions/DatabaseExtensions.cs | 3 +- .../Infrastructure/Mappings/WebhookProfile.cs | 2 +- .../Infrastructure/Options/SignalROptions.cs | 11 + Player.Api/Player.Api.csproj | 48 +- Player.Api/Program.cs | 27 +- Player.Api/Startup.cs | 443 +++++++++--------- Player.Api/appsettings.Production.json | 95 ++++ Player.Api/appsettings.json | 4 + global.json | 4 +- 28 files changed, 804 insertions(+), 540 deletions(-) create mode 100644 Player.Api.Data/Data/Models/Entry.cs create mode 100644 Player.Api.Data/Data/PlayerContextFactory.cs create mode 100644 Player.Api/Infrastructure/DbInterceptors/EventInterceptor.cs delete mode 100644 Player.Api/Infrastructure/DbInterceptors/EventTransactionInterceptor.cs create mode 100644 Player.Api/Infrastructure/Options/SignalROptions.cs create mode 100644 Player.Api/appsettings.Production.json diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b8117f9..9a09cbb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,73 +1,20 @@ -name: Publish Docker Images +name: Build and Publish Image on: pull_request: branches: - development push: - branches: [ development, staging ] + branches: [development, staging] release: - types: [ "published" ] - workflow_dispatch: - inputs: - tagName: - description: 'Tag of the image you want to build and push' - required: true + types: ["published"] jobs: - build: - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v2 - - - name: Prepare - id: prep - run: | - DOCKER_IMAGE=cmusei/player-api - VERSION=development - if [[ ! -z "${{ github.event.inputs.tagName }}" ]]; then - VERSION=${{ github.event.inputs.tagName }} - TAGS="${DOCKER_IMAGE}:${VERSION}" - elif [[ $GITHUB_REF == refs/tags/* ]]; then - VERSION=${GITHUB_REF#refs/tags/} - MAJORMINORVERSION=$(echo $VERSION | grep -oP '(\d+)\.(\d+)') - TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${MAJORMINORVERSION}" - elif [[ $GITHUB_REF == refs/heads/* ]]; then - VERSION=$(echo ${GITHUB_REF#refs/heads/} | sed -r 's#/+#-#g') - TAGS="${DOCKER_IMAGE}:${VERSION}" - fi - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo ::set-output name=push::false - echo "event is pull_request, not pushing image" - else - echo ::set-output name=push::true - echo "event is not pull_request, pushing image" - fi - echo ::set-output name=version::${VERSION} - echo ::set-output name=tags::${TAGS} - echo ::set-output name=created::$(date -u +'%Y-%m-%dT%H:%M:%SZ') - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - - name: Login to DockerHub - if: github.event_name != 'pull_request' - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - - - name: Build and push - uses: docker/build-push-action@v2 - with: - context: . - file: ./Dockerfile - push: ${{ steps.prep.outputs.push }} - pull: true - tags: ${{ steps.prep.outputs.tags }} - labels: | - org.opencontainers.image.source=${{ github.event.repository.clone_url }} - org.opencontainers.image.created=${{ steps.prep.outputs.created }} - org.opencontainers.image.revision=${{ github.sha }} + build-and-publish: + name: Build and Publish + uses: cmu-sei/Crucible-Github-Actions/.github/workflows/docker-build.yaml@docker-v1.0 + with: + imageName: cmusei/player-api + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 981a5d1..fd8a81b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ # #multi-stage target: dev # -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS dev +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS dev -ENV ASPNETCORE_URLS=http://0.0.0.0:4300 \ - ASPNETCORE_ENVIRONMENT=DEVELOPMENT +ENV ASPNETCORE_HTTP_PORTS=4300 +ENV ASPNETCORE_ENVIRONMENT=DEVELOPMENT COPY . /app WORKDIR /app @@ -14,16 +14,14 @@ CMD ["dotnet", "run"] # #multi-stage target: prod # -FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS prod +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS prod ARG commit ENV COMMIT=$commit +ENV DOTNET_HOSTBUILDER__RELOADCONFIGCHANGE=false COPY --from=dev /app/dist /app WORKDIR /app -ENV ASPNETCORE_URLS=http://*:80 +ENV ASPNETCORE_HTTP_PORTS=80 EXPOSE 80 -CMD ["dotnet", "Player.Api.dll"] - -RUN apt-get update && \ - apt-get install -y jq +CMD ["dotnet", "Player.Api.dll"] \ No newline at end of file diff --git a/Player.Api.Data/Data/Extensions/ModelBuilderExtensions.cs b/Player.Api.Data/Data/Extensions/ModelBuilderExtensions.cs index e877e02..3f03bb7 100644 --- a/Player.Api.Data/Data/Extensions/ModelBuilderExtensions.cs +++ b/Player.Api.Data/Data/Extensions/ModelBuilderExtensions.cs @@ -45,8 +45,8 @@ public static void AddPostgresUUIDGeneration(this ModelBuilder builder) .GetEntityTypes() .SelectMany(t => t.GetProperties()) .Where(p => p.ClrType == typeof(Guid)) - .Select(p => builder.Entity(p.DeclaringEntityType.ClrType).Property(p.Name)) - .Where(p => p.Metadata.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAdd && + .Select(p => builder.Entity(p.DeclaringType.ClrType).Property(p.Name)) + .Where(p => p.Metadata.ValueGenerated == ValueGenerated.OnAdd && p.Metadata.IsPrimaryKey()) ) { diff --git a/Player.Api.Data/Data/Models/Entry.cs b/Player.Api.Data/Data/Models/Entry.cs new file mode 100644 index 0000000..af05630 --- /dev/null +++ b/Player.Api.Data/Data/Models/Entry.cs @@ -0,0 +1,60 @@ +// Copyright 2024 Carnegie Mellon University. All Rights Reserved. +// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace Player.Api.Data; + +public class Entry +{ + public object Entity { get; set; } + public EntityState State { get; set; } + public IEnumerable Properties { get; set; } + private Dictionary IsPropertyModified { get; set; } = new(); + + public Entry(EntityEntry entry, Entry oldEntry = null) + { + Entity = entry.Entity; + State = entry.State; + Properties = entry.Properties; + + ProcessOldEntry(oldEntry); + + foreach (var prop in Properties) + { + IsPropertyModified[prop.Metadata.Name] = prop.IsModified; + } + } + + private void ProcessOldEntry(Entry oldEntry) + { + if (oldEntry == null) return; + + if (oldEntry.State != EntityState.Unchanged && oldEntry.State != EntityState.Detached) + { + State = oldEntry.State; + } + + var modifiedProperties = oldEntry.GetModifiedProperties(); + + foreach (var property in Properties) + { + if (modifiedProperties.Contains(property.Metadata.Name)) + { + property.IsModified = true; + } + } + } + + public string[] GetModifiedProperties() + { + return IsPropertyModified + .Where(x => x.Value) + .Select(x => x.Key) + .ToArray(); + } +} \ No newline at end of file diff --git a/Player.Api.Data/Data/PlayerContext.cs b/Player.Api.Data/Data/PlayerContext.cs index 7a89a15..d480151 100644 --- a/Player.Api.Data/Data/PlayerContext.cs +++ b/Player.Api.Data/Data/PlayerContext.cs @@ -2,22 +2,19 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure.Internal; using Player.Api.Data.Data.Models; using Player.Api.Data.Data.Models.Webhooks; using Player.Api.Data.Data.Extensions; -using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; +using System; namespace Player.Api.Data.Data { public class PlayerContext : DbContext { - private DbContextOptions _options; + // Needed for EventInterceptor + public IServiceProvider ServiceProvider; - public PlayerContext(DbContextOptions options) : base(options) - { - _options = options; - } + public PlayerContext(DbContextOptions options) : base(options) { } public DbSet Users { get; set; } public DbSet Views { get; set; } diff --git a/Player.Api.Data/Data/PlayerContextFactory.cs b/Player.Api.Data/Data/PlayerContextFactory.cs new file mode 100644 index 0000000..55528ab --- /dev/null +++ b/Player.Api.Data/Data/PlayerContextFactory.cs @@ -0,0 +1,30 @@ +// Copyright 2024 Carnegie Mellon University. All Rights Reserved. +// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. + +using System; +using Microsoft.EntityFrameworkCore; + +namespace Player.Api.Data.Data; + +public class PlayerContextFactory : IDbContextFactory +{ + private readonly IDbContextFactory _pooledFactory; + private readonly IServiceProvider _serviceProvider; + + public PlayerContextFactory( + IDbContextFactory pooledFactory, + IServiceProvider serviceProvider) + { + _pooledFactory = pooledFactory; + _serviceProvider = serviceProvider; + } + + public PlayerContext CreateDbContext() + { + var context = _pooledFactory.CreateDbContext(); + + // Inject the current scope's ServiceProvider + context.ServiceProvider = _serviceProvider; + return context; + } +} \ No newline at end of file diff --git a/Player.Api.Data/Player.Api.Data.csproj b/Player.Api.Data/Player.Api.Data.csproj index 5a2716a..cc7fec6 100644 --- a/Player.Api.Data/Player.Api.Data.csproj +++ b/Player.Api.Data/Player.Api.Data.csproj @@ -1,11 +1,8 @@ - - net6.0 + net8.0 - - + - - + \ No newline at end of file diff --git a/Player.Api.Migrations.PostgreSQL/Migrations/20180425120959_notifications.Designer.cs b/Player.Api.Migrations.PostgreSQL/Migrations/20180425120959_notifications.Designer.cs index acd42cd..a4cc78d 100644 --- a/Player.Api.Migrations.PostgreSQL/Migrations/20180425120959_notifications.Designer.cs +++ b/Player.Api.Migrations.PostgreSQL/Migrations/20180425120959_notifications.Designer.cs @@ -17,7 +17,7 @@ namespace Player.Api.Migrations.PostgreSQL.Migrations.Player { [DbContext(typeof(PlayerContext))] [Migration("20180425120959_notifications")] - partial class notifications + partial class Notifications { protected override void BuildTargetModel(ModelBuilder modelBuilder) { diff --git a/Player.Api.Migrations.PostgreSQL/Migrations/20180425120959_notifications.cs b/Player.Api.Migrations.PostgreSQL/Migrations/20180425120959_notifications.cs index f76a761..942750c 100644 --- a/Player.Api.Migrations.PostgreSQL/Migrations/20180425120959_notifications.cs +++ b/Player.Api.Migrations.PostgreSQL/Migrations/20180425120959_notifications.cs @@ -9,7 +9,7 @@ namespace Player.Api.Migrations.PostgreSQL.Migrations.Player { - public partial class notifications : Migration + public partial class Notifications : Migration { protected override void Up(MigrationBuilder migrationBuilder) { diff --git a/Player.Api.Migrations.PostgreSQL/Migrations/20201203194327_file-table.Designer.cs b/Player.Api.Migrations.PostgreSQL/Migrations/20201203194327_file-table.Designer.cs index 9cc6ce4..9503f42 100644 --- a/Player.Api.Migrations.PostgreSQL/Migrations/20201203194327_file-table.Designer.cs +++ b/Player.Api.Migrations.PostgreSQL/Migrations/20201203194327_file-table.Designer.cs @@ -14,7 +14,7 @@ namespace Player.Api.Migrations.PostgreSQL.Migrations { [DbContext(typeof(PlayerContext))] [Migration("20201203194327_file-table")] - partial class filetable + partial class FileTable { protected override void BuildTargetModel(ModelBuilder modelBuilder) { diff --git a/Player.Api.Migrations.PostgreSQL/Migrations/20201203194327_file-table.cs b/Player.Api.Migrations.PostgreSQL/Migrations/20201203194327_file-table.cs index 7af3d1c..5787ad5 100644 --- a/Player.Api.Migrations.PostgreSQL/Migrations/20201203194327_file-table.cs +++ b/Player.Api.Migrations.PostgreSQL/Migrations/20201203194327_file-table.cs @@ -7,7 +7,7 @@ namespace Player.Api.Migrations.PostgreSQL.Migrations { - public partial class filetable : Migration + public partial class FileTable : Migration { protected override void Up(MigrationBuilder migrationBuilder) { diff --git a/Player.Api.Migrations.PostgreSQL/Migrations/20201208170359_add-team-ids.Designer.cs b/Player.Api.Migrations.PostgreSQL/Migrations/20201208170359_add-team-ids.Designer.cs index 888b1c1..62c1644 100644 --- a/Player.Api.Migrations.PostgreSQL/Migrations/20201208170359_add-team-ids.Designer.cs +++ b/Player.Api.Migrations.PostgreSQL/Migrations/20201208170359_add-team-ids.Designer.cs @@ -15,7 +15,7 @@ namespace Player.Api.Migrations.PostgreSQL.Migrations { [DbContext(typeof(PlayerContext))] [Migration("20201208170359_add-team-ids")] - partial class addteamids + partial class AddTeamIds { protected override void BuildTargetModel(ModelBuilder modelBuilder) { diff --git a/Player.Api.Migrations.PostgreSQL/Migrations/20201208170359_add-team-ids.cs b/Player.Api.Migrations.PostgreSQL/Migrations/20201208170359_add-team-ids.cs index 8ffc2ae..e973bc8 100644 --- a/Player.Api.Migrations.PostgreSQL/Migrations/20201208170359_add-team-ids.cs +++ b/Player.Api.Migrations.PostgreSQL/Migrations/20201208170359_add-team-ids.cs @@ -7,7 +7,7 @@ namespace Player.Api.Migrations.PostgreSQL.Migrations { - public partial class addteamids : Migration + public partial class AddTeamIds : Migration { protected override void Up(MigrationBuilder migrationBuilder) { diff --git a/Player.Api.Migrations.PostgreSQL/Migrations/PlayerContextModelSnapshot.cs b/Player.Api.Migrations.PostgreSQL/Migrations/PlayerContextModelSnapshot.cs index 75c1721..e596c40 100644 --- a/Player.Api.Migrations.PostgreSQL/Migrations/PlayerContextModelSnapshot.cs +++ b/Player.Api.Migrations.PostgreSQL/Migrations/PlayerContextModelSnapshot.cs @@ -1,7 +1,7 @@ -// Copyright 2022 Carnegie Mellon University. All Rights Reserved. +// Copyright 2024 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -// +// using System; using System.Collections.Generic; using Microsoft.EntityFrameworkCore; @@ -69,7 +69,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ViewId"); - b.ToTable("applications"); + b.ToTable("applications", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.ApplicationInstanceEntity", b => @@ -98,7 +98,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TeamId"); - b.ToTable("application_instances"); + b.ToTable("application_instances", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.ApplicationTemplateEntity", b => @@ -131,7 +131,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("application_templates"); + b.ToTable("application_templates", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.FileEntity", b => @@ -162,7 +162,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ViewId"); - b.ToTable("files"); + b.ToTable("files", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.NotificationEntity", b => @@ -224,7 +224,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Key"); - b.ToTable("notifications"); + b.ToTable("notifications", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.PermissionEntity", b => @@ -256,7 +256,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Key", "Value") .IsUnique(); - b.ToTable("permissions"); + b.ToTable("permissions", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.RoleEntity", b => @@ -276,7 +276,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Name") .IsUnique(); - b.ToTable("roles"); + b.ToTable("roles", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.RolePermissionEntity", b => @@ -302,7 +302,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("RoleId", "PermissionId") .IsUnique(); - b.ToTable("role_permissions"); + b.ToTable("role_permissions", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.TeamEntity", b => @@ -331,7 +331,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ViewId"); - b.ToTable("teams"); + b.ToTable("teams", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.TeamMembershipEntity", b => @@ -369,7 +369,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TeamId", "UserId") .IsUnique(); - b.ToTable("team_memberships"); + b.ToTable("team_memberships", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.TeamPermissionEntity", b => @@ -395,7 +395,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("TeamId", "PermissionId") .IsUnique(); - b.ToTable("team_permissions"); + b.ToTable("team_permissions", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.UserEntity", b => @@ -426,7 +426,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("RoleId"); - b.ToTable("users"); + b.ToTable("users", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.UserPermissionEntity", b => @@ -452,7 +452,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("UserId", "PermissionId") .IsUnique(); - b.ToTable("user_permissions"); + b.ToTable("user_permissions", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.ViewEntity", b => @@ -483,7 +483,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ParentViewId"); - b.ToTable("views"); + b.ToTable("views", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.ViewMembershipEntity", b => @@ -515,7 +515,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ViewId", "UserId") .IsUnique(); - b.ToTable("view_memberships"); + b.ToTable("view_memberships", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.Webhooks.PendingEventEntity", b => @@ -546,7 +546,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SubscriptionId"); - b.ToTable("pending_events"); + b.ToTable("pending_events", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.Webhooks.WebhookSubscriptionEntity", b => @@ -579,7 +579,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("webhooks"); + b.ToTable("webhooks", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.Webhooks.WebhookSubscriptionEventTypeEntity", b => @@ -603,7 +603,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("SubscriptionId", "EventType") .IsUnique(); - b.ToTable("webhook_subscription_event_types"); + b.ToTable("webhook_subscription_event_types", (string)null); }); modelBuilder.Entity("Player.Api.Data.Data.Models.ApplicationEntity", b => diff --git a/Player.Api.Migrations.PostgreSQL/Player.Api.Migrations.PostgreSQL.csproj b/Player.Api.Migrations.PostgreSQL/Player.Api.Migrations.PostgreSQL.csproj index 05e7793..1c71d77 100644 --- a/Player.Api.Migrations.PostgreSQL/Player.Api.Migrations.PostgreSQL.csproj +++ b/Player.Api.Migrations.PostgreSQL/Player.Api.Migrations.PostgreSQL.csproj @@ -1,15 +1,11 @@ - - net6.0 + net8.0 - - + - - - + \ No newline at end of file diff --git a/Player.Api/.config/dotnet-tools.json b/Player.Api/.config/dotnet-tools.json index 1a1607f..aa43289 100644 --- a/Player.Api/.config/dotnet-tools.json +++ b/Player.Api/.config/dotnet-tools.json @@ -3,10 +3,8 @@ "isRoot": true, "tools": { "swashbuckle.aspnetcore.cli": { - "version": "6.2.3", - "commands": [ - "swagger" - ] + "version": "6.7.3", + "commands": ["swagger"] } } } \ No newline at end of file diff --git a/Player.Api/Controllers/FileController.cs b/Player.Api/Controllers/FileController.cs index 6d54344..09541f0 100644 --- a/Player.Api/Controllers/FileController.cs +++ b/Player.Api/Controllers/FileController.cs @@ -11,6 +11,7 @@ using Player.Api.Services; using Player.Api.ViewModels; using Swashbuckle.AspNetCore.Annotations; +using Microsoft.AspNetCore.Http; namespace Player.Api.Controllers { @@ -96,13 +97,13 @@ public async Task Download(Guid fileId, CancellationToken ct) if (IsPdf(fileName)) { - Response.Headers.Add("Content-Disposition", "inline"); + Response.Headers.Append("Content-Disposition", "inline"); return File(stream, "application/pdf", fileName); } else if (IsImage(fileName)) { - Response.Headers.Add("Content-Disposition", "inline"); + Response.Headers.Append("Content-Disposition", "inline"); // We need the extension without the . character var ext = Path.GetExtension(fileName).Substring(1); return File(stream, "image/" + ext, fileName); diff --git a/Player.Api/Infrastructure/DbInterceptors/EventInterceptor.cs b/Player.Api/Infrastructure/DbInterceptors/EventInterceptor.cs new file mode 100644 index 0000000..051c3f2 --- /dev/null +++ b/Player.Api/Infrastructure/DbInterceptors/EventInterceptor.cs @@ -0,0 +1,276 @@ +// Copyright 2022 Carnegie Mellon University. All Rights Reserved. +// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Player.Api.Data; +using Player.Api.Data.Data; +using Player.Api.Events; + +namespace Player.Api.Infrastructure.DbInterceptors; + +/// +/// Intercepts saves to the database and generate Entity events from them. +/// +/// As of EF7, transactions are not always created by SaveChanges for performance reasons, so we have to +/// handle both TransactionCommitted and SavedChanges. If a transaction is in progress, +/// SavedChanges will not generate the events and it will instead happen in TransactionCommitted. +/// +public class EventInterceptor : DbTransactionInterceptor, ISaveChangesInterceptor +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + private List Entries { get; set; } = new List(); + + public EventInterceptor( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public override async Task TransactionCommittedAsync(DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default) + { + await TransactionCommittedInternal(eventData); + await base.TransactionCommittedAsync(transaction, eventData, cancellationToken); + } + + public override void TransactionCommitted(DbTransaction transaction, TransactionEndEventData eventData) + { + TransactionCommittedInternal(eventData).Wait(); + base.TransactionCommitted(transaction, eventData); + } + + private async Task TransactionCommittedInternal(TransactionEndEventData eventData) + { + try + { + await PublishEvents(eventData.Context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in TransactionCommitted"); + } + } + + public int SavedChanges(SaveChangesCompletedEventData eventData, int result) + { + SavedChangesInternal(eventData, false).Wait(); + return result; + } + + public async ValueTask SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) + { + await SavedChangesInternal(eventData, true); + return result; + } + + private async Task SavedChangesInternal(SaveChangesCompletedEventData eventData, bool async) + { + try + { + if (eventData.Context.Database.CurrentTransaction == null) + { + if (async) + { + await PublishEvents(eventData.Context); + } + else + { + PublishEvents(eventData.Context).Wait(); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in SavedChanges"); + } + } + + /// + /// Called before SaveChanges is performed. This saves the changed Entities to be used at the end of the + /// transaction for creating events from the final set of changes. May be called multiple times for a single + /// transaction. + /// + /// + public InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + SaveEntries(eventData.Context); + return result; + } + + public ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default(CancellationToken)) + { + SaveEntries(eventData.Context); + return new ValueTask>(result); + } + + /// + /// Creates and publishes events from the current set of entity changes. + /// + /// The DbContext used for this transaction + /// + private async Task PublishEvents(DbContext dbContext) + { + IServiceScope scope = null; + + try + { + // Try to get required services from the current scope that has been injected into the + // dbContext from the ContextFactory. This allows us to use the same scope in the event handlers. + // If no ServiceProvider exists on the context, create a new scope with the root ServiceProvider. + IMediator mediator = null; + + if (dbContext is PlayerContext) + { + var context = dbContext as PlayerContext; + if (context.ServiceProvider != null) + { + mediator = context.ServiceProvider.GetRequiredService(); + } + } + + if (mediator == null) + { + scope = _serviceProvider.CreateScope(); + mediator = scope.ServiceProvider.GetRequiredService(); + } + + await PublishEventsInternal(mediator); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in PublishEvents"); + } + finally + { + if (scope != null) + { + scope.Dispose(); + } + } + } + + private async Task PublishEventsInternal(IMediator mediator) + { + var events = new List(); + var entries = GetEntries(); + + foreach (var entry in entries) + { + var entityType = entry.Entity.GetType(); + Type eventType = null; + + string[] modifiedProperties = null; + + switch (entry.State) + { + case EntityState.Added: + eventType = typeof(EntityCreated<>).MakeGenericType(entityType); + + // Make sure properties generated by the db are set + var generatedProps = entry.Properties + .Where(x => x.Metadata.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAdd) + .ToList(); + + foreach (var prop in generatedProps) + { + entityType.GetProperty(prop.Metadata.Name).SetValue(entry.Entity, prop.CurrentValue); + } + + break; + case EntityState.Modified: + eventType = typeof(EntityUpdated<>).MakeGenericType(entityType); + modifiedProperties = entry.GetModifiedProperties(); + break; + case EntityState.Deleted: + eventType = typeof(EntityDeleted<>).MakeGenericType(entityType); + break; + } + + if (eventType != null) + { + INotification evt; + + if (modifiedProperties != null) + { + evt = Activator.CreateInstance(eventType, new[] { entry.Entity, modifiedProperties }) as INotification; + } + else + { + evt = Activator.CreateInstance(eventType, new[] { entry.Entity }) as INotification; + } + + + if (evt != null) + { + events.Add(evt); + } + } + } + + foreach (var evt in events) + { + await mediator.Publish(evt); + } + } + + private Entry[] GetEntries() + { + var entries = Entries + .Where(x => x.State == EntityState.Added || + x.State == EntityState.Modified || + x.State == EntityState.Deleted) + .ToList(); + + Entries.Clear(); + return entries.ToArray(); + } + + /// + /// Keeps track of changes across multiple savechanges in a transaction, without duplicates + /// + private void SaveEntries(DbContext db) + { + foreach (var entry in db.ChangeTracker.Entries()) + { + // find value of id property + var id = entry.Properties + .FirstOrDefault(x => + x.Metadata.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAdd)?.CurrentValue; + + // find matching existing entry, if any + Entry e = null; + + if (id != null) + { + e = Entries.FirstOrDefault(x => id.Equals(x.Properties.FirstOrDefault(y => + y.Metadata.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAdd)?.CurrentValue)); + } + + if (e != null) + { + // if entry already exists, mark which properties were previously modified, + // remove old entry and add new one, to avoid duplicates + var newEntry = new Entry(entry, e); + Entries.Remove(e); + Entries.Add(newEntry); + } + else + { + Entries.Add(new Entry(entry)); + } + } + } +} \ No newline at end of file diff --git a/Player.Api/Infrastructure/DbInterceptors/EventTransactionInterceptor.cs b/Player.Api/Infrastructure/DbInterceptors/EventTransactionInterceptor.cs deleted file mode 100644 index d2dd4a3..0000000 --- a/Player.Api/Infrastructure/DbInterceptors/EventTransactionInterceptor.cs +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright 2022 Carnegie Mellon University. All Rights Reserved. -// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Data.Common; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Player.Api.Events; - -namespace Player.Api.Infrastructure.DbInterceptors -{ - public class EventTransactionInterceptor : DbTransactionInterceptor - { - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - public EventTransactionInterceptor( - IServiceProvider serviceProvider, - ILogger logger) - { - _serviceProvider = serviceProvider; - _logger = logger; - } - - public override async Task TransactionCommittedAsync( - DbTransaction transaction, - TransactionEndEventData eventData, - CancellationToken cancellationToken = default(CancellationToken)) - { - try - { - await PublishEvents(eventData); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in EventTransactionInterceptor"); - } - finally - { - await base.TransactionCommittedAsync(transaction, eventData, cancellationToken); - } - } - - public override async void TransactionCommitted( - DbTransaction transaction, - TransactionEndEventData eventData) - { - try - { - await PublishEvents(eventData); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in EventTransactionInterceptor"); - } - finally - { - base.TransactionCommitted(transaction, eventData); - } - } - - private async Task PublishEvents(TransactionEndEventData eventData) - { - var entries = GetEntries(eventData.Context.ChangeTracker); - - using (var scope = _serviceProvider.CreateScope()) - { - var events = new List(); - var mediator = scope.ServiceProvider.GetRequiredService(); - - foreach (var entry in entries) - { - var entityType = entry.Entity.GetType(); - Type eventType = null; - - string[] modifiedProperties = null; - - switch (entry.State) - { - case EntityState.Added: - eventType = typeof(EntityCreated<>).MakeGenericType(entityType); - - // Make sure properties generated by the db are set - var generatedProps = entry.Properties - .Where(x => x.Metadata.ValueGenerated == Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.OnAdd) - .ToList(); - - foreach (var prop in generatedProps) - { - entityType.GetProperty(prop.Metadata.Name).SetValue(entry.Entity, prop.CurrentValue); - } - - break; - case EntityState.Modified: - eventType = typeof(EntityUpdated<>).MakeGenericType(entityType); - modifiedProperties = entry.Properties - .Where(x => x.IsModified) - .Select(x => x.Metadata.Name) - .ToArray(); - break; - case EntityState.Deleted: - eventType = typeof(EntityDeleted<>).MakeGenericType(entityType); - break; - } - - if (eventType != null) - { - INotification evt; - - if (modifiedProperties != null) - { - evt = Activator.CreateInstance(eventType, new[] { entry.Entity, modifiedProperties }) as INotification; - } - else - { - evt = Activator.CreateInstance(eventType, new[] { entry.Entity }) as INotification; - } - - - if (evt != null) - { - events.Add(evt); - } - } - } - - foreach (var evt in events) - { - await mediator.Publish(evt); - } - } - } - - private EntityEntry[] GetEntries(ChangeTracker changeTracker) - { - var entries = changeTracker.Entries() - .Where(x => x.State == EntityState.Added || - x.State == EntityState.Modified || - x.State == EntityState.Deleted) - .ToList(); - - return entries.ToArray(); - } - } -} \ No newline at end of file diff --git a/Player.Api/Infrastructure/Extensions/DatabaseExtensions.cs b/Player.Api/Infrastructure/Extensions/DatabaseExtensions.cs index a14204a..1b815be 100644 --- a/Player.Api/Infrastructure/Extensions/DatabaseExtensions.cs +++ b/Player.Api/Infrastructure/Extensions/DatabaseExtensions.cs @@ -17,12 +17,13 @@ using Player.Api.ViewModels.Webhooks; using Player.Api.Data.Data.Models.Webhooks; using AutoMapper; +using Microsoft.Extensions.Hosting; namespace Player.Api.Extensions { public static class DatabaseExtensions { - public static IWebHost InitializeDatabase(this IWebHost webHost) + public static IHost InitializeDatabase(this IHost webHost) { using (var scope = webHost.Services.CreateScope()) { diff --git a/Player.Api/Infrastructure/Mappings/WebhookProfile.cs b/Player.Api/Infrastructure/Mappings/WebhookProfile.cs index 047d040..09e9d55 100644 --- a/Player.Api/Infrastructure/Mappings/WebhookProfile.cs +++ b/Player.Api/Infrastructure/Mappings/WebhookProfile.cs @@ -26,7 +26,7 @@ public WebhookProfile() CreateMap() .ForMember(dest => dest.EventTypes, opt => opt.MapFrom(src => src.EventTypes.Select(et => new WebhookSubscriptionEventTypeEntity(Guid.Empty, et)))) .ForMember(dest => dest.EventTypes, opts => opts.PreCondition((src) => src.EventTypes != null)) - .ForAllOtherMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); + .ForAllMembers(opts => opts.Condition((src, dest, srcMember) => srcMember != null)); } } } \ No newline at end of file diff --git a/Player.Api/Infrastructure/Options/SignalROptions.cs b/Player.Api/Infrastructure/Options/SignalROptions.cs new file mode 100644 index 0000000..d6d6aba --- /dev/null +++ b/Player.Api/Infrastructure/Options/SignalROptions.cs @@ -0,0 +1,11 @@ +// Copyright 2022 Carnegie Mellon University. All Rights Reserved. +// Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. + +namespace Player.Api.Options +{ + public class SignalROptions + { + public bool EnableStatefulReconnect { get; set; } = true; + public long StatefulReconnectBufferSizeBytes { get; set; } = 100000; + } +} diff --git a/Player.Api/Player.Api.csproj b/Player.Api/Player.Api.csproj index bbaa2fb..a386c50 100644 --- a/Player.Api/Player.Api.csproj +++ b/Player.Api/Player.Api.csproj @@ -1,45 +1,39 @@ - - net6.0 + net8.0 bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml 1591 - - - - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - - + + + + + + + + - - - - - - + + + + + + - - - + \ No newline at end of file diff --git a/Player.Api/Program.cs b/Player.Api/Program.cs index 07b01b1..a1074c2 100644 --- a/Player.Api/Program.cs +++ b/Player.Api/Program.cs @@ -1,15 +1,15 @@ // Copyright 2022 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Player.Api.Extensions; -namespace Player.Api +namespace Player.Api; + +public class Program { - public class Program - { public static void Main(string[] args) { CreateWebHostBuilder(args) @@ -18,13 +18,12 @@ public static void Main(string[] args) .Run(); } - public static IWebHostBuilder CreateWebHostBuilder(string[] args) - { - var configuration = new ConfigurationBuilder().AddCommandLine(args).Build(); - - return WebHost.CreateDefaultBuilder(args) - .UseConfiguration(configuration) - .UseStartup(); - } + public static IHostBuilder CreateWebHostBuilder(string[] args) + { + return Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + }); } -} +} \ No newline at end of file diff --git a/Player.Api/Startup.cs b/Player.Api/Startup.cs index a5fa80f..c2afa58 100644 --- a/Player.Api/Startup.cs +++ b/Player.Api/Startup.cs @@ -2,7 +2,6 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. using System; -using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Principal; using System.Text.Json.Serialization; @@ -27,265 +26,279 @@ using Player.Api.Infrastructure.Mappings; using Player.Api.Options; using Player.Api.Services; -using MediatR; using Player.Api.Infrastructure.DbInterceptors; +using Microsoft.IdentityModel.JsonWebTokens; +using AutoMapper.Internal; -namespace Player.Api +namespace Player.Api; + +public class Startup { - public class Startup + private readonly Options.AuthorizationOptions _authOptions = new(); + private readonly SignalROptions _signalROptions = new(); + private IConfiguration Configuration { get; } + private const string _routePrefix = "api"; + private string _pathbase; + + public Startup(IConfiguration configuration) { - public Options.AuthorizationOptions _authOptions = new Options.AuthorizationOptions(); - public IConfiguration Configuration { get; } - private const string _routePrefix = "api"; - private string _pathbase; + Configuration = configuration; + Configuration.GetSection("Authorization").Bind(_authOptions); + Configuration.GetSection("SignalR").Bind(_signalROptions); + _pathbase = Configuration["PathBase"]; + } - public Startup(IConfiguration configuration) + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + // Add Azure Application Insights, if connection string is supplied + string appInsights = Configuration["ApplicationInsights:ConnectionString"]; + if (!string.IsNullOrWhiteSpace(appInsights)) { - Configuration = configuration; - Configuration.GetSection("Authorization").Bind(_authOptions); - _pathbase = Configuration["PathBase"]; + services.AddApplicationInsightsTelemetry(); } - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) + var provider = Configuration["Database:Provider"]; + switch (provider) { - // Add Azure Application Insights, if connection string is supplied - string appInsights = Configuration["ApplicationInsights:ConnectionString"]; - if (!string.IsNullOrWhiteSpace(appInsights)) - { - services.AddApplicationInsightsTelemetry(); - } - - var provider = Configuration["Database:Provider"]; - switch (provider) - { - case "InMemory": - services.AddDbContextPool((serviceProvider, opt) => opt - .AddInterceptors(serviceProvider.GetRequiredService()) + case "InMemory": + services.AddPooledDbContextFactory((serviceProvider, opt) => opt + .AddInterceptors(serviceProvider.GetRequiredService()) .UseInMemoryDatabase("api")); - break; - case "Sqlite": - case "SqlServer": - case "PostgreSQL": - services.AddDbContextPool((serviceProvider, builder) => builder - .AddInterceptors(serviceProvider.GetRequiredService()) - .UseConfiguredDatabase(Configuration)); - break; - } - var connectionString = Configuration.GetConnectionString(DatabaseExtensions.DbProvider(Configuration)); - switch (provider) - { - case "Sqlite": - services.AddHealthChecks().AddSqlite(connectionString, tags: new[] { "ready", "live" }); - break; - case "SqlServer": - services.AddHealthChecks().AddSqlServer(connectionString, tags: new[] { "ready", "live" }); - break; - case "PostgreSQL": - services.AddHealthChecks().AddNpgSql(connectionString, tags: new[] { "ready", "live" }); - break; - } + break; + case "Sqlite": + case "SqlServer": + case "PostgreSQL": + services.AddPooledDbContextFactory((serviceProvider, builder) => builder + .AddInterceptors(serviceProvider.GetRequiredService()) + .UseConfiguredDatabase(Configuration)); + break; + } + var connectionString = Configuration.GetConnectionString(DatabaseExtensions.DbProvider(Configuration)); + switch (provider) + { + case "Sqlite": + services.AddHealthChecks().AddSqlite(connectionString, tags: new[] { "ready", "live" }); + break; + case "SqlServer": + services.AddHealthChecks().AddSqlServer(connectionString, tags: new[] { "ready", "live" }); + break; + case "PostgreSQL": + services.AddHealthChecks().AddNpgSql(connectionString, tags: new[] { "ready", "live" }); + break; + } + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService().CreateDbContext()); - services.AddOptions() - .Configure(Configuration.GetSection("Database")) - .AddScoped(config => config.GetService>().CurrentValue) - .Configure(Configuration.GetSection("ClaimsTransformation")) - .AddScoped(config => config.GetService>().CurrentValue) + services.AddOptions() + .Configure(Configuration.GetSection("Database")) + .AddScoped(config => config.GetService>().CurrentValue) - .Configure(Configuration.GetSection("SeedData")) - .AddScoped(config => config.GetService>().CurrentValue) + .Configure(Configuration.GetSection("ClaimsTransformation")) + .AddScoped(config => config.GetService>().CurrentValue) - .Configure(Configuration.GetSection("FileUpload")) - .AddScoped(config => config.GetService>().CurrentValue) + .Configure(Configuration.GetSection("SeedData")) + .AddScoped(config => config.GetService>().CurrentValue) - .Configure(Configuration.GetSection("Authorization")) - .AddSingleton(config => config.GetService>().CurrentValue); + .Configure(Configuration.GetSection("FileUpload")) + .AddScoped(config => config.GetService>().CurrentValue) - services.AddCors(options => options.UseConfiguredCors(Configuration.GetSection("CorsPolicy"))); + .Configure(Configuration.GetSection("Authorization")) + .AddSingleton(config => config.GetService>().CurrentValue); - services.AddSignalR() - .AddJsonProtocol(options => - { - options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - }); + services.AddCors(options => options.UseConfiguredCors(Configuration.GetSection("CorsPolicy"))); - services.AddMvc(options => - { - options.Filters.Add(typeof(ValidateModelStateFilter)); - options.Filters.Add(typeof(JsonExceptionFilter)); - }) - .AddJsonOptions(options => + services.AddSignalR(o => o.StatefulReconnectBufferSize = _signalROptions.StatefulReconnectBufferSizeBytes) + .AddJsonProtocol(options => { - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); - services.AddSwagger(_authOptions); - - JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + services.AddMvc(options => + { + options.Filters.Add(typeof(ValidateModelStateFilter)); + options.Filters.Add(typeof(JsonExceptionFilter)); + }) + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.Authority = _authOptions.Authority; - options.RequireHttpsMetadata = _authOptions.RequireHttpsMetadata; - options.SaveToken = true; + services.AddSwagger(_authOptions); - string[] validAudiences; + JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear(); - if (_authOptions.ValidAudiences != null && _authOptions.ValidAudiences.Any()) - { - validAudiences = _authOptions.ValidAudiences; - } - else - { - validAudiences = _authOptions.AuthorizationScope.Split(' '); - } + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = _authOptions.Authority; + options.RequireHttpsMetadata = _authOptions.RequireHttpsMetadata; + options.SaveToken = true; - options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters - { - ValidateAudience = _authOptions.ValidateAudience, - ValidAudiences = validAudiences - }; + string[] validAudiences; - }); - - services.AddRouting(options => + if (_authOptions.ValidAudiences != null && _authOptions.ValidAudiences.Any()) { - options.LowercaseUrls = true; - }); + validAudiences = _authOptions.ValidAudiences; + } + else + { + validAudiences = _authOptions.AuthorizationScope.Split(' '); + } - services.AddMemoryCache(); - services.AddMediatR(typeof(Startup)); - services.AddTransient(); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - - services.AddSingleton(); - services.AddScoped(p => p.GetService().HttpContext.User); - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(x => x.GetService()); - services.AddSingleton(x => x.GetService()); - services.AddHttpClient(); - - ApplyPolicies(services); - - services.AddAutoMapper(cfg => + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters { - cfg.ForAllPropertyMaps( - pm => pm.SourceType != null && Nullable.GetUnderlyingType(pm.SourceType) == pm.DestinationType, - (pm, c) => c.MapFrom(new IgnoreNullSourceValues(), pm.SourceMember.Name)); - }, typeof(Startup)); - } + ValidateAudience = _authOptions.ValidateAudience, + ValidAudiences = validAudiences + }; + }); - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + services.AddRouting(options => { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } + options.LowercaseUrls = true; + }); + + services.AddMemoryCache(); + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining()); + services.AddTransient(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddSingleton(); + services.AddScoped(p => p.GetService().HttpContext.User); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(x => x.GetService()); + services.AddSingleton(x => x.GetService()); + services.AddHttpClient(); + + ApplyPolicies(services); + + services.AddAutoMapper(cfg => + { + cfg.Internal().ForAllPropertyMaps( + pm => pm.SourceType != null && Nullable.GetUnderlyingType(pm.SourceType) == pm.DestinationType, + (pm, c) => c.MapFrom(new IgnoreNullSourceValues(), pm.SourceMember.Name)); + }, typeof(Startup)); + } - app.UsePathBase(_pathbase); + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } - app.UseRouting(); - app.UseCors("default"); + app.UsePathBase(_pathbase); - //move any querystring jwt to Auth bearer header - app.Use(async (context, next) => + app.UseRouting(); + app.UseCors("default"); + + //move any querystring jwt to Auth bearer header + app.Use(async (context, next) => + { + if (string.IsNullOrWhiteSpace(context.Request.Headers["Authorization"]) + && context.Request.QueryString.HasValue) { - if (string.IsNullOrWhiteSpace(context.Request.Headers["Authorization"]) - && context.Request.QueryString.HasValue) - { - string token = context.Request.QueryString.Value - .Substring(1) - .Split('&') - .SingleOrDefault(x => x.StartsWith("bearer="))?.Split('=')[1]; + string token = context.Request.QueryString.Value + .Substring(1) + .Split('&') + .SingleOrDefault(x => x.StartsWith("bearer="))?.Split('=')[1]; - if (!String.IsNullOrWhiteSpace(token)) - context.Request.Headers.Add("Authorization", new[] { $"Bearer {token}" }); - } + if (!String.IsNullOrWhiteSpace(token)) + context.Request.Headers.Append("Authorization", new[] { $"Bearer {token}" }); + } - await next.Invoke(); + await next.Invoke(); - }); + }); - app.UseSwagger(); - app.UseSwaggerUI(c => + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.RoutePrefix = _routePrefix; + c.SwaggerEndpoint($"{_pathbase}/swagger/v1/swagger.json", "Player v1"); + c.OAuthClientId(_authOptions.ClientId); + c.OAuthClientSecret(_authOptions.ClientSecret); + c.OAuthAppName(_authOptions.ClientName); + c.OAuthUsePkce(); + }); + + app.UseAuthentication(); + app.UseAuthorization(); + + app.UseEndpoints(endpoints => { - c.RoutePrefix = _routePrefix; - c.SwaggerEndpoint($"{_pathbase}/swagger/v1/swagger.json", "Player v1"); - c.OAuthClientId(_authOptions.ClientId); - c.OAuthClientSecret(_authOptions.ClientSecret); - c.OAuthAppName(_authOptions.ClientName); - c.OAuthUsePkce(); - }); + endpoints.MapControllers(); + endpoints.MapHealthChecks($"/{_routePrefix}/health/ready", new HealthCheckOptions() + { + Predicate = (check) => check.Tags.Contains("ready"), + }); - app.UseAuthentication(); - app.UseAuthorization(); + endpoints.MapHealthChecks($"/{_routePrefix}/health/live", new HealthCheckOptions() + { + Predicate = (check) => check.Tags.Contains("live"), + }); - app.UseEndpoints(endpoints => + endpoints.MapHub("/hubs/view", options => { - endpoints.MapControllers(); - endpoints.MapHealthChecks($"/{_routePrefix}/health/ready", new HealthCheckOptions() - { - Predicate = (check) => check.Tags.Contains("ready"), - }); - - endpoints.MapHealthChecks($"/{_routePrefix}/health/live", new HealthCheckOptions() - { - Predicate = (check) => check.Tags.Contains("live"), - }); - endpoints.MapHub("/hubs/view"); - endpoints.MapHub("/hubs/team"); - endpoints.MapHub("/hubs/user"); - } - ); - } + options.AllowStatefulReconnects = _signalROptions.EnableStatefulReconnect; + }); + endpoints.MapHub("/hubs/team", options => + { + options.AllowStatefulReconnects = _signalROptions.EnableStatefulReconnect; + }); + endpoints.MapHub("/hubs/user", options => + { + options.AllowStatefulReconnects = _signalROptions.EnableStatefulReconnect; + }); + } + ); + } - private void ApplyPolicies(IServiceCollection services) + private void ApplyPolicies(IServiceCollection services) + { + services.AddAuthorization(options => { - services.AddAuthorization(options => - { - // Require all scopes in authOptions - var policyBuilder = new AuthorizationPolicyBuilder().RequireAuthenticatedUser(); - Array.ForEach(_authOptions.AuthorizationScope.Split(' '), x => policyBuilder.RequireClaim("scope", x)); - - options.DefaultPolicy = policyBuilder.Build(); - }); - - // TODO: Add these automatically with reflection? - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(); - } + // Require all scopes in authOptions + var policyBuilder = new AuthorizationPolicyBuilder().RequireAuthenticatedUser(); + Array.ForEach(_authOptions.AuthorizationScope.Split(' '), x => policyBuilder.RequireClaim("scope", x)); + + options.DefaultPolicy = policyBuilder.Build(); + }); + + // TODO: Add these automatically with reflection? + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); } -} +} \ No newline at end of file diff --git a/Player.Api/appsettings.Production.json b/Player.Api/appsettings.Production.json new file mode 100644 index 0000000..ac3a464 --- /dev/null +++ b/Player.Api/appsettings.Production.json @@ -0,0 +1,95 @@ +{ + "PathBase": "", + "Logging": { + "IncludeScopes": false, + "Debug": { + "LogLevel": { + "Default": "Warning" + } + }, + "Console": { + "LogLevel": { + "Default": "Warning" + } + } + }, + "CorsPolicy": { + "Origins": ["http://localhost:4301", "http://localhost:4303"], + "Methods": [], + "Headers": [], + "AllowAnyOrigin": false, + "AllowAnyMethod": true, + "AllowAnyHeader": true, + "SupportsCredentials": true + }, + "ConnectionStrings": { + "PostgreSQL": "Server=host.docker.internal;Port=5432;Database=player_api;Username=postgres;Password=postgres;IncludeErrorDetail=true", + "Sqlite": "Data Source=player_api.db", + "SqlServer": "Server=(localdb)\\mssqllocaldb;Database=player_api;Trusted_Connection=True;MultipleActiveResultSets=true" + }, + "Database": { + "AutoMigrate": true, + "DevModeRecreate": false, + "Provider": "PostgreSQL" + }, + "Authorization": { + "Authority": "http://host.docker.internal:8080/realms/crucible", + "AuthorizationUrl": "http://host.docker.internal:8080/realms/crucible/protocol/openid-connect/auth", + "TokenUrl": "http://host.docker.internal:8080/realms/crucible/protocol/openid-connect/token", + "AuthorizationScope": "player", + "ClientId": "player.api", + "ClientName": "Player Swagger UI", + "ClientSecret": "", + "RequireHttpsMetadata": false + }, + "ClaimsTransformation": { + "EnableCaching": true, + "CacheExpirationSeconds": 60 + }, + "Notifications": { + "UserIconUrl": "/assets/img/SP_Icon_User.png", + "SystemIconUrl": "/assets/img/SP_Icon_Alert.png", + "HelpDeskApplicationName": "Help Desk" + }, + "FileUpload": { + "basePath": "player/files", + "maxSize": "64000000", + "allowedExtensions": [ + ".pdf", + ".png", + ".jpg", + ".jpeg", + ".doc", + ".docx", + ".gif", + ".txt", + ".exe" + ] + }, + "SeedData": { + "Permissions": [ + { + "Key": "SystemAdmin", + "Value": "true", + "Description": "Can do anything", + "ReadOnly": true + }, + { + "Key": "ViewAdmin", + "Value": "true", + "Description": "Can edit an View, Add/Remove Teams/Members, etc", + "ReadOnly": true + } + ], + "SystemAdminIds": ["9b3b331c-10c1-448b-8114-21b2586d8e38"], + "Subscriptions": [ + { + "Name": "Vm Api", + "CallbackUri": "http://host.docker.internal:4302/api/callback", + "ClientId": "vm.webhooks", + "ClientSecret": "d20c24db88964ec9ace280de38c0afbe", + "EventTypes": ["ViewCreated", "ViewDeleted"] + } + ] + } +} diff --git a/Player.Api/appsettings.json b/Player.Api/appsettings.json index 1e1a672..eeb3fb9 100644 --- a/Player.Api/appsettings.json +++ b/Player.Api/appsettings.json @@ -70,6 +70,10 @@ "ApplicationInsights": { "ConnectionString": "" }, + "SignalR": { + "EnableStatefulReconnect": true, + "StatefulReconnectBufferSizeBytes": 100000 + }, "SeedData": { "Permissions": [ { diff --git a/global.json b/global.json index 89ff686..4570606 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "6.0.100", + "version": "8.0.304", "allowPrerelease": false, "rollForward": "latestMinor" } -} \ No newline at end of file +}