diff --git a/.dockerignore b/.dockerignore index 2d15e48..3729ff0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,20 +1,25 @@ +**/.classpath **/.dockerignore **/.env **/.git **/.gitignore +**/.project +**/.settings +**/.toolstarget **/.vs **/.vscode **/*.*proj.user +**/*.dbmdl +**/*.jfm **/azds.yaml -**/charts **/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log **/obj -**/Dockerfile -**/Dockerfile.develop -**/docker-compose.yml -**/docker-compose.*.yml -**/*.dbmdl -**/*.jfm **/secrets.dev.yaml **/values.dev.yaml -**/.toolstarget \ No newline at end of file +LICENSE +README.md \ No newline at end of file diff --git a/WebApp/doorrequest/.editorconfig b/.editorconfig similarity index 88% rename from WebApp/doorrequest/.editorconfig rename to .editorconfig index f43e844..d3b8cfc 100644 --- a/WebApp/doorrequest/.editorconfig +++ b/.editorconfig @@ -119,13 +119,13 @@ csharp_prefer_static_local_function = true:warning csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async # Code-block preferences -csharp_prefer_simple_using_statement = true +csharp_prefer_simple_using_statement = true:suggestion csharp_prefer_braces = when_multiline:warning # Expression-level preferences csharp_prefer_simple_default_expression = true:warning csharp_style_deconstructed_variable_declaration = true -csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion csharp_style_inlined_variable_declaration = false csharp_style_pattern_local_over_anonymous_function = true:warning csharp_style_prefer_index_operator = true:warning @@ -264,3 +264,31 @@ dotnet_naming_style.generic_parameter_style.required_prefix = T dotnet_naming_style.generic_parameter_style.required_suffix = dotnet_naming_style.generic_parameter_style.word_separator = dotnet_naming_style.generic_parameter_style.capitalization = pascal_case +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion + +[*.{cs,vb}] +dotnet_style_coalesce_expression = true:warning +dotnet_style_null_propagation = true:warning +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_object_initializer = true:warning +dotnet_style_collection_initializer = true:warning +dotnet_style_prefer_simplified_boolean_expressions = true:warning +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_explicit_tuple_names = true:warning +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:warning +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = crlf \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 6961638..d316a40 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -3,15 +3,21 @@ on: push: branches: [master] -env: - REGISTRY: ghcr.io - jobs: publish: + name: Publish ${{ matrix.image }} runs-on: ubuntu-latest permissions: contents: write packages: write + strategy: + fail-fast: false + matrix: + include: + - dockerfile: ./API/DoorRequest.API/Dockerfile + image: ghcr.io/brixel/door-request-api + - dockerfile: ./Web/Dockerfile + image: ghcr.io/brixel/door-request-web steps: - name: Checkout uses: actions/checkout@v4 @@ -20,7 +26,7 @@ jobs: - name: Log in to the Container registry uses: docker/login-action@v3 with: - registry: ${{ env.REGISTRY }} + registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Install GitVersion @@ -34,14 +40,14 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/brixel/door-request-api + images: ${{ matrix.image }} tags: type=semver,pattern={{version}},value=${{ steps.gitversion.outputs.semVer }} - - name: Build and push - id: docker_build + - name: Build and push docker image + id: docker_build_api uses: docker/build-push-action@v5 with: context: . push: true - file: API/DoorRequest.API/Dockerfile + file: ${{ matrix.dockerfile }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/API/DoorRequest.API/Controllers/DoorRequestController.cs b/API/DoorRequest.API/Controllers/DoorRequestController.cs index 3b508a6..bed6cca 100644 --- a/API/DoorRequest.API/Controllers/DoorRequestController.cs +++ b/API/DoorRequest.API/Controllers/DoorRequestController.cs @@ -1,9 +1,13 @@ -using DoorRequest.API.Config; +using System.Threading.Tasks; + +using DoorRequest.API.Config; using DoorRequest.API.Services; + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using System.Threading.Tasks; + +using Shared.Authorization; namespace DoorRequest.API.Controllers; @@ -12,24 +16,24 @@ namespace DoorRequest.API.Controllers; [Authorize] public class DoorRequestController : ControllerBase { - private readonly IDoorRequestService _doorRequestService; + private readonly IDoorService _doorService; private readonly LockConfiguration _lockConfiguration; - public DoorRequestController(IDoorRequestService doorRequestService, IOptions lockConfiguration) + public DoorRequestController(IDoorService doorService, IOptions lockConfiguration) { - _doorRequestService = doorRequestService; + _doorService = doorService ?? throw new System.ArgumentNullException(nameof(doorService)); _lockConfiguration = lockConfiguration.Value; } [HttpPost("open")] - [Authorize(Roles = Authorization.Roles.TwentyFourSevenAccess)] + [Authorize(Roles = Roles.TwentyFourSevenAccess)] public async Task OpenDoorRequest() { - return await _doorRequestService.OpenDoor(); + return await _doorService.OpenDoor(); } [HttpGet("code")] - [Authorize(Roles = Authorization.Roles.KeyVaultCodeAccess)] + [Authorize(Roles = Roles.KeyVaultCodeAccess)] public int GetLockCode() { return _lockConfiguration.Code; diff --git a/API/DoorRequest.API/DoorRequest.API.csproj b/API/DoorRequest.API/DoorRequest.API.csproj index 83bdbc7..19236bb 100644 --- a/API/DoorRequest.API/DoorRequest.API.csproj +++ b/API/DoorRequest.API/DoorRequest.API.csproj @@ -22,6 +22,10 @@ + + + + diff --git a/API/DoorRequest.API/Health.http b/API/DoorRequest.API/Health.http new file mode 100644 index 0000000..488bb30 --- /dev/null +++ b/API/DoorRequest.API/Health.http @@ -0,0 +1,6 @@ +# For more info on HTTP files go to https://aka.ms/vs/httpfile +@rootUrl=https://localhost:5001 + +HEAD {{rootUrl}}/healthz + +# HEAD {{rootUrl}}/healthz \ No newline at end of file diff --git a/API/DoorRequest.API/Program.cs b/API/DoorRequest.API/Program.cs index ca06168..9b3fcf6 100644 --- a/API/DoorRequest.API/Program.cs +++ b/API/DoorRequest.API/Program.cs @@ -1,6 +1,9 @@ -using DoorRequest.API.Authorization; +using System; +using System.IdentityModel.Tokens.Jwt; + using DoorRequest.API.Config; using DoorRequest.API.Services; + using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; @@ -9,10 +12,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; + using Serilog; using Serilog.Events; -using System; -using System.IdentityModel.Tokens.Jwt; + +using Shared.Authorization; Log.Logger = new LoggerConfiguration() .MinimumLevel.Debug() @@ -33,6 +37,7 @@ builder.Services.AddSwaggerGen(); builder.Services.AddControllers(); + builder.Services.AddHealthChecks(); var authOptions = builder.Configuration.GetSection(AuthenticationConfiguration.SectionName).Get(); @@ -68,22 +73,12 @@ .Build(); }); - builder.Services.AddCors(options => - { - //TODO: Fix CORS - options.AddPolicy("CorsPolicy", builder => - builder.AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader()); - }); - builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(DoorConfiguration.SectionName)) .ValidateDataAnnotations() .ValidateOnStart(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection(nameof(LockConfiguration))); var app = builder.Build(); @@ -96,12 +91,17 @@ } app.UseHttpsRedirection(); - app.UseCors("CorsPolicy"); + app.UseCors(builder => + //TODO: Fix CORS + builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader()); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); + app.MapHealthChecks("/healthz"); app.Run(); } diff --git a/API/DoorRequest.API/Properties/launchSettings.json b/API/DoorRequest.API/Properties/launchSettings.json index a328f99..c733f44 100644 --- a/API/DoorRequest.API/Properties/launchSettings.json +++ b/API/DoorRequest.API/Properties/launchSettings.json @@ -1,22 +1,5 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:63559", - "sslPort": 44300 - } - }, - "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "api/values", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "DoorRequest.API": { "commandName": "Project", "launchUrl": "api/values", @@ -37,5 +20,14 @@ "useSSL": true, "sslPort": 44301 } + }, + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:63559", + "sslPort": 44300 + } } } \ No newline at end of file diff --git a/API/DoorRequest.API/Services/DoorRequestService.cs b/API/DoorRequest.API/Services/DoorRequestService.cs deleted file mode 100644 index 16c3878..0000000 --- a/API/DoorRequest.API/Services/DoorRequestService.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.Extensions.Logging; -using System.Threading.Tasks; - -namespace DoorRequest.API.Services; - -public class DoorRequestService : IDoorRequestService -{ - private readonly IBrixelOpenDoorClient _brixelOpenDoorClient; - private readonly ILogger _logger; - - public DoorRequestService(IBrixelOpenDoorClient brixelOpenDoorClient, ILogger logger) - { - _brixelOpenDoorClient = brixelOpenDoorClient; - _logger = logger; - } - public async Task OpenDoor() - { - _logger.LogInformation("Sending request to open the door via MQTT"); - return await _brixelOpenDoorClient.OpenDoor(); - } -} diff --git a/API/DoorRequest.API/Services/BrixelOpenDoorClient.cs b/API/DoorRequest.API/Services/DoorService.cs similarity index 84% rename from API/DoorRequest.API/Services/BrixelOpenDoorClient.cs rename to API/DoorRequest.API/Services/DoorService.cs index 4d1d8c7..c4ce5ad 100644 --- a/API/DoorRequest.API/Services/BrixelOpenDoorClient.cs +++ b/API/DoorRequest.API/Services/DoorService.cs @@ -1,5 +1,6 @@  using DoorRequest.API.Config; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using MQTTnet; using MQTTnet.Client; @@ -11,12 +12,13 @@ namespace DoorRequest.API.Services; -public class BrixelOpenDoorClient : IBrixelOpenDoorClient +public class DoorService : IDoorService { private readonly string _topic; private readonly MqttClientOptions _options; + private readonly ILogger _logger; - public BrixelOpenDoorClient(IOptions options) + public DoorService(IOptions options, ILogger logger) { _topic = options.Value.Topic; var optionsBuilder = new MqttClientOptionsBuilder() @@ -44,10 +46,12 @@ public BrixelOpenDoorClient(IOptions options) } _options = optionsBuilder.Build(); + _logger = logger ?? throw new System.ArgumentNullException(nameof(logger)); } public async Task OpenDoor() { + _logger.LogInformation("Sending request to open the door via MQTT"); using var mqttClient = new MqttFactory().CreateMqttClient(); await mqttClient.ConnectAsync(_options, CancellationToken.None); var message = new MqttApplicationMessageBuilder() diff --git a/API/DoorRequest.API/Services/IBrixelOpenDoorClient.cs b/API/DoorRequest.API/Services/IBrixelOpenDoorClient.cs deleted file mode 100644 index 3dcfc9b..0000000 --- a/API/DoorRequest.API/Services/IBrixelOpenDoorClient.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Threading.Tasks; - -namespace DoorRequest.API.Services; - -public interface IBrixelOpenDoorClient -{ - Task OpenDoor(); -} \ No newline at end of file diff --git a/API/DoorRequest.API/Services/IDoorRequestService.cs b/API/DoorRequest.API/Services/IDoorService.cs similarity index 73% rename from API/DoorRequest.API/Services/IDoorRequestService.cs rename to API/DoorRequest.API/Services/IDoorService.cs index 27dc5af..ce39028 100644 --- a/API/DoorRequest.API/Services/IDoorRequestService.cs +++ b/API/DoorRequest.API/Services/IDoorService.cs @@ -2,7 +2,7 @@ namespace DoorRequest.API.Services; -public interface IDoorRequestService +public interface IDoorService { Task OpenDoor(); } \ No newline at end of file diff --git a/API/DoorRequest.API/appsettings.Development.json b/API/DoorRequest.API/appsettings.Development.json index e4846b9..49ccd61 100644 --- a/API/DoorRequest.API/appsettings.Development.json +++ b/API/DoorRequest.API/appsettings.Development.json @@ -21,10 +21,13 @@ "Authentication": { "Authority": "http://localhost:5302/realms/DevRealm" }, - "MQTTDoorConfiguration": { - "Server": "localhost", - "Port": 5303, - "UseSSL": false, - "Topic": "some/topic" - } + "MQTTDoorConfiguration": { + "Server": "localhost", + "Port": 5303, + "UseSSL": false, + "Topic": "some/topic" + }, + "LockConfiguration": { + "Code": 123 + } } diff --git a/DoorRequest.sln b/DoorRequest.sln index 41a3feb..aedccff 100644 --- a/DoorRequest.sln +++ b/DoorRequest.sln @@ -1,13 +1,17 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.6.33723.286 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DoorRequest.API", "API\DoorRequest.API\DoorRequest.API.csproj", "{263FC65F-673F-42AF-BBDC-7D194BCF58AC}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Web", "Web\Web.csproj", "{D21DF4B7-5113-482B-9FD4-7541B2E2F8E9}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution items", "Solution items", "{CC45F04F-0025-46C0-B8D9-5072C68FD717}" ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig docker-compose.yml = docker-compose.yml + GitVersion.yml = GitVersion.yml EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastructure", "{1CD813E1-F654-4340-8DCA-0935FBBA9EE2}" @@ -22,6 +26,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mosquitto", "Mosquitto", "{ Infrastructure\Mosquitto\config\mosquitto.conf = Infrastructure\Mosquitto\config\mosquitto.conf EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Shared", "Shared\Shared.csproj", "{51C4CCF5-F70A-45CF-BF76-9DF83B92E731}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -32,10 +38,22 @@ Global {263FC65F-673F-42AF-BBDC-7D194BCF58AC}.Debug|Any CPU.Build.0 = Debug|Any CPU {263FC65F-673F-42AF-BBDC-7D194BCF58AC}.Release|Any CPU.ActiveCfg = Release|Any CPU {263FC65F-673F-42AF-BBDC-7D194BCF58AC}.Release|Any CPU.Build.0 = Release|Any CPU + {D21DF4B7-5113-482B-9FD4-7541B2E2F8E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D21DF4B7-5113-482B-9FD4-7541B2E2F8E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D21DF4B7-5113-482B-9FD4-7541B2E2F8E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D21DF4B7-5113-482B-9FD4-7541B2E2F8E9}.Release|Any CPU.Build.0 = Release|Any CPU + {51C4CCF5-F70A-45CF-BF76-9DF83B92E731}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51C4CCF5-F70A-45CF-BF76-9DF83B92E731}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51C4CCF5-F70A-45CF-BF76-9DF83B92E731}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51C4CCF5-F70A-45CF-BF76-9DF83B92E731}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C695FD5A-7CC3-4226-A8C4-7C89EA489FA6} = {1CD813E1-F654-4340-8DCA-0935FBBA9EE2} + {13A6DFE6-8ADB-4493-91C3-48803473E3E1} = {1CD813E1-F654-4340-8DCA-0935FBBA9EE2} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3BEE30D4-3A4A-4C6F-BA05-29AF80363B10} EndGlobalSection diff --git a/API/DoorRequest.API/Authorization/CustomClaims.cs b/Shared/Authorization/CustomClaims.cs similarity index 65% rename from API/DoorRequest.API/Authorization/CustomClaims.cs rename to Shared/Authorization/CustomClaims.cs index b1dbf0b..11b1def 100644 --- a/API/DoorRequest.API/Authorization/CustomClaims.cs +++ b/Shared/Authorization/CustomClaims.cs @@ -1,4 +1,4 @@ -namespace DoorRequest.API.Authorization; +namespace Shared.Authorization; public static class CustomClaims { diff --git a/API/DoorRequest.API/Authorization/Roles.cs b/Shared/Authorization/Roles.cs similarity index 81% rename from API/DoorRequest.API/Authorization/Roles.cs rename to Shared/Authorization/Roles.cs index 89458cd..83a25c9 100644 --- a/API/DoorRequest.API/Authorization/Roles.cs +++ b/Shared/Authorization/Roles.cs @@ -1,4 +1,4 @@ -namespace DoorRequest.API.Authorization; +namespace Shared.Authorization; public static class Roles { diff --git a/Shared/Shared.csproj b/Shared/Shared.csproj new file mode 100644 index 0000000..132c02c --- /dev/null +++ b/Shared/Shared.csproj @@ -0,0 +1,9 @@ + + + + net6.0 + enable + enable + + + diff --git a/Web/App.razor b/Web/App.razor new file mode 100644 index 0000000..e5794c4 --- /dev/null +++ b/Web/App.razor @@ -0,0 +1,36 @@ +@using Web.Services; +@inject IConnectionStatusService ConnectionStatusService; + + + + + + + @if (context.User.Identity?.IsAuthenticated != true) + { + + Please log in + } + else + { +

You are not authorized to access this resource.

+ } +
+
+ +
+ + Not found + +

Sorry, there's nothing at this address.

+
+
+
+
+ +@code { + protected override async Task OnInitializedAsync() + { + await ConnectionStatusService.StartPeriodicChecks(CancellationToken.None); + } +} \ No newline at end of file diff --git a/Web/Authorization/CustomUserFactory.cs b/Web/Authorization/CustomUserFactory.cs new file mode 100644 index 0000000..e584bfa --- /dev/null +++ b/Web/Authorization/CustomUserFactory.cs @@ -0,0 +1,48 @@ +using System.Security.Claims; +using System.Text.Json; + +using Microsoft.AspNetCore.Components.WebAssembly.Authentication; +using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal; + +namespace Web.Authorization; + +public class CustomUserFactory : AccountClaimsPrincipalFactory +{ + public CustomUserFactory(IAccessTokenProviderAccessor accessor) + : base(accessor) + { + } + + public async override ValueTask CreateUserAsync( + RemoteUserAccount account, + RemoteAuthenticationUserOptions options) + { + var user = await base.CreateUserAsync(account, options); + var claimsIdentity = (ClaimsIdentity)(user.Identity + ?? throw new Exception("Could not get user's Identity property")); + + if (account != null) + { + MapArrayClaimsToMultipleSeparateClaims(account, claimsIdentity); + } + + return user; + } + + private static void MapArrayClaimsToMultipleSeparateClaims(RemoteUserAccount account, ClaimsIdentity claimsIdentity) + { + foreach (var prop in account.AdditionalProperties) + { + var key = prop.Key; + var value = prop.Value; + if (value != null && + (value is JsonElement element && element.ValueKind == JsonValueKind.Array)) + { + claimsIdentity.RemoveClaim(claimsIdentity.FindFirst(prop.Key)); + var claims = element.EnumerateArray() + .Select(x => new Claim(prop.Key, x.ToString())); + claimsIdentity.AddClaims(claims); + } + } + } +} \ No newline at end of file diff --git a/Web/Configuration/ApiConfiguration.cs b/Web/Configuration/ApiConfiguration.cs new file mode 100644 index 0000000..554f7b7 --- /dev/null +++ b/Web/Configuration/ApiConfiguration.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Web.Configuration; + +public class ApiConfiguration +{ + public const string SectionName = "ApiConfiguration"; + + [Url, Required(AllowEmptyStrings = false)] + public string BaseUrl { get; set; } = default!; +} diff --git a/Web/Dockerfile b/Web/Dockerfile new file mode 100644 index 0000000..efda937 --- /dev/null +++ b/Web/Dockerfile @@ -0,0 +1,17 @@ +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env +#RUN apk add nodejs +#RUN apk add npm +RUN apt-get update && apt-get install -y python3 +WORKDIR /app +COPY . ./ +RUN dotnet workload restore +#RUN npm --prefix Web install +RUN dotnet build "Web/Web.csproj" -c Release +RUN dotnet publish "Web/Web.csproj" -c Release -o output +RUN rm output/wwwroot/appsettings.json.br && rm output/wwwroot/appsettings.json.gz + +FROM nginx:alpine +WORKDIR /usr/share/nginx/html +COPY --from=build-env /app/output/wwwroot . +COPY Web/nginx.conf /etc/nginx/nginx.conf +EXPOSE 80 \ No newline at end of file diff --git a/Web/Extensions/ApiOptionsExtensions.cs b/Web/Extensions/ApiOptionsExtensions.cs new file mode 100644 index 0000000..0ea38e5 --- /dev/null +++ b/Web/Extensions/ApiOptionsExtensions.cs @@ -0,0 +1,15 @@ +using Web.Configuration; + +namespace Web.Extensions; + +public static class ApiOptionsExtensions +{ + public static IServiceCollection ConfigureApiOptions(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(ApiConfiguration.SectionName)) + .ValidateDataAnnotations(); + + return services; + } +} diff --git a/Web/Extensions/ApiServiceExtensions.cs b/Web/Extensions/ApiServiceExtensions.cs new file mode 100644 index 0000000..5fa3f14 --- /dev/null +++ b/Web/Extensions/ApiServiceExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Options; + +using Web.Configuration; +using Web.Services; + +namespace Web.Extensions; + +public static class ApiServiceExtensions +{ + public static void AddApiService(this IServiceCollection services) + { + services.AddScoped(); + + services.AddHttpClient((provider, options) => + { + var configuration = provider.GetRequiredService>().Value; + + options.BaseAddress = new Uri(configuration.BaseUrl); + }); + } +} \ No newline at end of file diff --git a/Web/Extensions/DoorServiceExtensions.cs b/Web/Extensions/DoorServiceExtensions.cs new file mode 100644 index 0000000..a5dc9dd --- /dev/null +++ b/Web/Extensions/DoorServiceExtensions.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.WebAssembly.Authentication; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +using Web.Configuration; +using Web.Services; + +namespace Web.Extensions; + +public static class DoorServiceExtensions +{ + public static void AddDoorService(this IServiceCollection services) + { + services.AddScoped(); + + services.TryAddTransient(); + + services.AddHttpClient((provider, options) => + { + var configuration = provider.GetRequiredService>().Value; + + options.BaseAddress = new Uri($"{configuration.BaseUrl}/api/doorrequest/"); + }).AddHttpMessageHandler(); + } +} + +public class ApiAuthorizationMessageHandler : AuthorizationMessageHandler +{ + public ApiAuthorizationMessageHandler( + IAccessTokenProvider provider, + NavigationManager navigation, + IOptions configuration) : base(provider, navigation) + { + ConfigureHandler( + authorizedUrls: new[] { configuration.Value.BaseUrl }); + } +} \ No newline at end of file diff --git a/Web/Extensions/MudBlazorExtensions.cs b/Web/Extensions/MudBlazorExtensions.cs new file mode 100644 index 0000000..bf03892 --- /dev/null +++ b/Web/Extensions/MudBlazorExtensions.cs @@ -0,0 +1,15 @@ +using MudBlazor; +using MudBlazor.Services; + +namespace Web.Extensions; + +public static class MudBlazorExtensions +{ + public static IServiceCollection AddMudBlazor(this IServiceCollection services) + { + return services.AddMudServices(config => + { + config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomCenter; + }); + } +} diff --git a/Web/Pages/About.razor b/Web/Pages/About.razor new file mode 100644 index 0000000..0382eff --- /dev/null +++ b/Web/Pages/About.razor @@ -0,0 +1,78 @@ +@page "/about" +@using System.Security.Claims +@using Microsoft.AspNetCore.Components.Authorization +@using System.Globalization +@using global::Shared.Authorization; +@inject AuthenticationStateProvider AuthenticationStateProvider + +

About

+ +

@authMessage

+ +

+ Current culture: @CultureInfo.CurrentUICulture +

+ +

+ + Logout + +

+ +

Claims

+ +

+ @if (claims.Count() > 0) + { +

    + @foreach (var claim in claims) + { +
  • @claim.Type: @claim.Value
  • + } +
+ } +

+ +

Roles

+ +

+ @if (roles.Count() > 0) + { +

    + @foreach (var role in roles) + { +
  • @role
  • + } +
+ } + else + { + No roles found. + } +

+ +@code { + private string? authMessage; + private IEnumerable claims = Enumerable.Empty(); + private IEnumerable roles = Enumerable.Empty(); + + protected override async Task OnInitializedAsync() + { + var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + + if (user?.Identity?.IsAuthenticated ?? false) + { + authMessage = $"{user.Identity.Name} is authenticated."; + claims = user.Claims; + roles = user.Claims.Where(c => c.Type == CustomClaims.Roles).Select(c => c.Value); + } + else + { + authMessage = "The user is NOT authenticated."; + } + } +} \ No newline at end of file diff --git a/Web/Pages/Authentication.razor b/Web/Pages/Authentication.razor new file mode 100644 index 0000000..6c74356 --- /dev/null +++ b/Web/Pages/Authentication.razor @@ -0,0 +1,7 @@ +@page "/authentication/{action}" +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication + + +@code{ + [Parameter] public string? Action { get; set; } +} diff --git a/Web/Pages/DoorControl.razor b/Web/Pages/DoorControl.razor new file mode 100644 index 0000000..302f447 --- /dev/null +++ b/Web/Pages/DoorControl.razor @@ -0,0 +1,82 @@ +@page "/" + +@using Microsoft.AspNetCore.Authorization; +@using Web.Services; +@using global::Shared.Authorization; + +@inject IDoorService DoorService; +@inject ISnackbar Snackbar; +@inject IConnectionStatusService ConnectionStatusService; + +@attribute [Authorize(Roles = Roles.TwentyFourSevenAccess)] + +
+ + @if (OpeningDoor) + { + + Opening door... + } + else + { + Buzz me in! + } + +
+
+ + + + + Key vault code + + + + @if (KeyVaultCode is not null) + { + @KeyVaultCode + } + else + { + + } + + + +
+ +@code { + private int? KeyVaultCode { get; set; } + + private bool OpeningDoor; + + private bool IsOnline => ConnectionStatusService.ApiReachable; + + public async Task OpenDoor() + { + OpeningDoor = true; + await DoorService.OpenDoor(CancellationToken.None); + Snackbar.Add("Door buzzer triggered successfully!", Severity.Success); + OpeningDoor = false; + } + + protected override async Task OnInitializedAsync() + { + ConnectionStatusService.AddOnDisconnectedAction((ct) => + { + Snackbar.Clear(); + Snackbar.Add("Can not connect to API service", Severity.Error); + StateHasChanged(); + return Task.CompletedTask; + }); + + ConnectionStatusService.AddOnConnectedAction((ct) => + { + Snackbar.Clear(); + Snackbar.Add("API Service reachable", Severity.Success); + StateHasChanged(); + return Task.CompletedTask; + }); + KeyVaultCode = await DoorService.GetCode(CancellationToken.None); + } +} diff --git a/Web/Program.cs b/Web/Program.cs new file mode 100644 index 0000000..76be470 --- /dev/null +++ b/Web/Program.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +using Shared.Authorization; + +using Web; +using Web.Authorization; +using Web.Extensions; +using Web.Services; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +builder.Services.AddOidcAuthentication(options => +{ + // Configure your authentication provider options here. + // For more information, see https://aka.ms/blazor-standalone-auth + builder.Configuration.Bind("Local", options.ProviderOptions); + options.UserOptions.RoleClaim = CustomClaims.Roles; +}).AddAccountClaimsPrincipalFactory(); + +builder.Services.AddMudBlazor(); + +builder.Services.ConfigureApiOptions(builder.Configuration); +builder.Services.AddDoorService(); +builder.Services.AddApiService(); +builder.Services.AddSingleton(); + +await builder.Build().RunAsync(); diff --git a/Web/Properties/launchSettings.json b/Web/Properties/launchSettings.json new file mode 100644 index 0000000..3afd294 --- /dev/null +++ b/Web/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "https": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7104" + } + }, + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:13524", + "sslPort": 44322 + } + } +} \ No newline at end of file diff --git a/Web/Services/ApiService.cs b/Web/Services/ApiService.cs new file mode 100644 index 0000000..4b10e6d --- /dev/null +++ b/Web/Services/ApiService.cs @@ -0,0 +1,29 @@ +namespace Web.Services; + +public interface IApiService +{ + Task IsHealthy(CancellationToken ct); +} + +public class ApiService : IApiService +{ + private readonly HttpClient _httpClient; + + public ApiService(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task IsHealthy(CancellationToken ct) + { + try + { + var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, "/healthz"), ct); + return response.IsSuccessStatusCode; + } + catch (HttpRequestException) + { + return false; + } + } +} diff --git a/Web/Services/ConnectionStatusService.cs b/Web/Services/ConnectionStatusService.cs new file mode 100644 index 0000000..fd30ee5 --- /dev/null +++ b/Web/Services/ConnectionStatusService.cs @@ -0,0 +1,59 @@ +namespace Web.Services; + +public class ConnectionStatusService : IConnectionStatusService +{ + private readonly IApiService _apiService; + public bool ApiReachable { get; private set; } = true; + + private readonly List> _onDisconnectedActions = new(); + private readonly List> _onConnectedActions = new(); + + public ConnectionStatusService(IApiService apiService) + { + _apiService = apiService ?? throw new ArgumentNullException(nameof(apiService)); + } + + public async Task StartPeriodicChecks(CancellationToken ct) + { + var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + while (!ct.IsCancellationRequested + && await timer.WaitForNextTickAsync(ct)) + { + var reachable = await _apiService.IsHealthy(ct); + if (reachable != ApiReachable) + { + ApiReachable = reachable; + if (reachable) + await OnConnected(ct); + else + await OnDisconnected(ct); + } + } + } + + public void AddOnDisconnectedAction(Func action) + { + _onDisconnectedActions.Add(action); + } + + public void AddOnConnectedAction(Func action) + { + _onConnectedActions.Add(action); + } + + private async Task OnDisconnected(CancellationToken ct) + { + foreach (var task in _onDisconnectedActions) + { + await task(ct); + } + } + + private async Task OnConnected(CancellationToken ct) + { + foreach (var task in _onConnectedActions) + { + await task(ct); + } + } +} \ No newline at end of file diff --git a/Web/Services/DoorService.cs b/Web/Services/DoorService.cs new file mode 100644 index 0000000..c017a74 --- /dev/null +++ b/Web/Services/DoorService.cs @@ -0,0 +1,28 @@ +using System.Net.Http.Json; + +namespace Web.Services; + +public interface IDoorService +{ + Task GetCode(CancellationToken ct); + Task OpenDoor(CancellationToken ct); +} + +public class DoorService : IDoorService +{ + private readonly HttpClient _httpClient; + + public DoorService(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task OpenDoor(CancellationToken ct) + { + var response = await _httpClient.PostAsync("open", null, ct); + response.EnsureSuccessStatusCode(); + } + + public Task GetCode(CancellationToken ct) + => _httpClient.GetFromJsonAsync("code", ct); +} diff --git a/Web/Services/IConnectionStatusService.cs b/Web/Services/IConnectionStatusService.cs new file mode 100644 index 0000000..66c40a7 --- /dev/null +++ b/Web/Services/IConnectionStatusService.cs @@ -0,0 +1,10 @@ +namespace Web.Services; + +public interface IConnectionStatusService +{ + bool ApiReachable { get; } + + void AddOnConnectedAction(Func action); + void AddOnDisconnectedAction(Func action); + Task StartPeriodicChecks(CancellationToken ct); +} \ No newline at end of file diff --git a/Web/Shared/LoginDisplay.razor b/Web/Shared/LoginDisplay.razor new file mode 100644 index 0000000..7227b00 --- /dev/null +++ b/Web/Shared/LoginDisplay.razor @@ -0,0 +1,40 @@ +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication +@inject NavigationManager Navigation + + + + + + Hello, @context.User.Identity?.Name! + + Log out + + + + + Log in + + + + + + + + + Hello, @context.User.Identity?.Name! + Log out + + + + Log in + + + + +@code { + private void BeginLogOut() + { + Navigation.NavigateToLogout("authentication/logout"); + } +} diff --git a/Web/Shared/MainLayout.razor b/Web/Shared/MainLayout.razor new file mode 100644 index 0000000..47ac04c --- /dev/null +++ b/Web/Shared/MainLayout.razor @@ -0,0 +1,74 @@ +@inherits LayoutComponentBase + +@Title + + + + + + + + + @Title + + + + + + + @Body + + + +@code { + private string Title = "DoorApp"; + private bool _isDarkMode; + + private MudThemeProvider _mudThemeProvider = default!; + + [Inject] + private IConfiguration Configuration { get; set; } = default!; + + private readonly MudTheme _currentTheme = new() + { + Palette = new PaletteLight + { + Primary = "#0A7BCF", + Secondary = "#4CAF50", + Info = "#64a7e2", + Success = "#2ECC40", + Warning = "#FFC107", + Error = "#FF0000", + AppbarBackground = "#212121", + TextPrimary = "#0A7BCF", + TextSecondary = "#4CAF50" + }, + PaletteDark = new PaletteDark + { + Primary = "00BFFF", + Secondary = "#800080", + Info = "#4169E1", + Success = "#32CD32", + Warning = "#FFA500", + Error = "#FF0000", + AppbarBackground = "#121212", + TextPrimary = "#E0E0E0", + TextSecondary = "#BDBDBD" + } + }; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + Title = Configuration.GetValue("AppName") ?? "DoorApp"; + if (firstRender) + { + _isDarkMode = await _mudThemeProvider.GetSystemPreference(); + StateHasChanged(); + } + } + + void ThemeToggle() => _isDarkMode = !_isDarkMode; +} \ No newline at end of file diff --git a/Web/Shared/MainLayout.razor.css b/Web/Shared/MainLayout.razor.css new file mode 100644 index 0000000..e69de29 diff --git a/Web/Shared/RedirectToLogin.razor b/Web/Shared/RedirectToLogin.razor new file mode 100644 index 0000000..a1cf400 --- /dev/null +++ b/Web/Shared/RedirectToLogin.razor @@ -0,0 +1,9 @@ +@using Microsoft.AspNetCore.Components.WebAssembly.Authentication +@inject NavigationManager Navigation + +@code { + protected override void OnInitialized() + { + Navigation.NavigateToLogin("authentication/login"); + } +} diff --git a/Web/Shared/SurveyPrompt.razor b/Web/Shared/SurveyPrompt.razor new file mode 100644 index 0000000..67b6b62 --- /dev/null +++ b/Web/Shared/SurveyPrompt.razor @@ -0,0 +1,16 @@ +
+ + @Title + + + Please take our + brief survey + + and tell us what you think. +
+ +@code { + // Demonstrates how a parent component can supply parameters + [Parameter] + public string? Title { get; set; } +} diff --git a/Web/Web.csproj b/Web/Web.csproj new file mode 100644 index 0000000..15c7969 --- /dev/null +++ b/Web/Web.csproj @@ -0,0 +1,45 @@ + + + + net7.0 + enable + enable + service-worker-assets.js + True + $(MSBuildProjectName) + + + + True + True + + + + True + True + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Web/_Imports.razor b/Web/_Imports.razor new file mode 100644 index 0000000..252a741 --- /dev/null +++ b/Web/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using Web +@using Web.Shared +@using MudBlazor \ No newline at end of file diff --git a/Web/nginx.conf b/Web/nginx.conf new file mode 100644 index 0000000..a5d350d --- /dev/null +++ b/Web/nginx.conf @@ -0,0 +1,15 @@ +events { } +http { + include mime.types; + types { + application/wasm; + } + server { + listen 80; + index index.html; + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html =404; + } + } +} \ No newline at end of file diff --git a/Web/wwwroot/appsettings.Development.json b/Web/wwwroot/appsettings.Development.json new file mode 100644 index 0000000..3fce7f4 --- /dev/null +++ b/Web/wwwroot/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Local": { + "Authority": "http://localhost:5302/realms/DevRealm", + "ClientId": "door-request-webapp", + "ResponseType": "code" + }, + "ApiConfiguration": { + "BaseUrl": "https://localhost:5001" + } +} diff --git a/Web/wwwroot/appsettings.json b/Web/wwwroot/appsettings.json new file mode 100644 index 0000000..4bbc06d --- /dev/null +++ b/Web/wwwroot/appsettings.json @@ -0,0 +1,7 @@ +{ + "Local": { + "Authority": "", + "ClientId": "" + }, + "AppName": "HCKSPC DoorApp" +} diff --git a/Web/wwwroot/css/app.css b/Web/wwwroot/css/app.css new file mode 100644 index 0000000..8c45091 --- /dev/null +++ b/Web/wwwroot/css/app.css @@ -0,0 +1,70 @@ +div#app { + height: 100vh; +} + +div#loader { + background-color: rgba(50,51,61,1); + display: flex; + justify-content: center; + padding-top: 4rem; + height: 100vh; +} + +.loader { + width: 48px; + height: 48px; + border-radius: 50%; + display: inline-block; + position: relative; + border: 3px solid; + border-color: #FFF #FFF transparent transparent; + box-sizing: border-box; + animation: rotation 1s linear infinite; +} + + .loader::after, + .loader::before { + content: ''; + box-sizing: border-box; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + margin: auto; + border: 3px solid; + border-color: transparent transparent #FF3D00 #FF3D00; + width: 40px; + height: 40px; + border-radius: 50%; + box-sizing: border-box; + animation: rotationBack 0.5s linear infinite; + transform-origin: center center; + } + + .loader::before { + width: 32px; + height: 32px; + border-color: #FFF #FFF transparent transparent; + animation: rotation 1.5s linear infinite; + } + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +@keyframes rotationBack { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(-360deg); + } +} diff --git a/Web/wwwroot/favicon.png b/Web/wwwroot/favicon.png new file mode 100644 index 0000000..8422b59 Binary files /dev/null and b/Web/wwwroot/favicon.png differ diff --git a/Web/wwwroot/icon-192.png b/Web/wwwroot/icon-192.png new file mode 100644 index 0000000..166f56d Binary files /dev/null and b/Web/wwwroot/icon-192.png differ diff --git a/Web/wwwroot/icon-512.png b/Web/wwwroot/icon-512.png new file mode 100644 index 0000000..c2dd484 Binary files /dev/null and b/Web/wwwroot/icon-512.png differ diff --git a/Web/wwwroot/index.html b/Web/wwwroot/index.html new file mode 100644 index 0000000..999024d --- /dev/null +++ b/Web/wwwroot/index.html @@ -0,0 +1,38 @@ + + + + + + + Web + + + + + + + + + + + + +
+
+ +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + + + + + diff --git a/Web/wwwroot/manifest.json b/Web/wwwroot/manifest.json new file mode 100644 index 0000000..a8fa3da --- /dev/null +++ b/Web/wwwroot/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Web", + "short_name": "Web", + "start_url": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#03173d", + "prefer_related_applications": false, + "icons": [ + { + "src": "icon-512.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "icon-192.png", + "type": "image/png", + "sizes": "192x192" + } + ] +} diff --git a/Web/wwwroot/service-worker.js b/Web/wwwroot/service-worker.js new file mode 100644 index 0000000..fe614da --- /dev/null +++ b/Web/wwwroot/service-worker.js @@ -0,0 +1,4 @@ +// In development, always fetch from the network and do not enable offline support. +// This is because caching would make development more difficult (changes would not +// be reflected on the first load after each change). +self.addEventListener('fetch', () => { }); diff --git a/Web/wwwroot/service-worker.published.js b/Web/wwwroot/service-worker.published.js new file mode 100644 index 0000000..0d9986f --- /dev/null +++ b/Web/wwwroot/service-worker.published.js @@ -0,0 +1,48 @@ +// Caution! Be sure you understand the caveats before publishing an application with +// offline support. See https://aka.ms/blazor-offline-considerations + +self.importScripts('./service-worker-assets.js'); +self.addEventListener('install', event => event.waitUntil(onInstall(event))); +self.addEventListener('activate', event => event.waitUntil(onActivate(event))); +self.addEventListener('fetch', event => event.respondWith(onFetch(event))); + +const cacheNamePrefix = 'offline-cache-'; +const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; +const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; +const offlineAssetsExclude = [ /^service-worker\.js$/ ]; + +async function onInstall(event) { + console.info('Service worker: Install'); + + // Fetch and cache all matching items from the assets manifest + const assetsRequests = self.assetsManifest.assets + .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) + .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) + .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' })); + await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); +} + +async function onActivate(event) { + console.info('Service worker: Activate'); + + // Delete unused caches + const cacheKeys = await caches.keys(); + await Promise.all(cacheKeys + .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) + .map(key => caches.delete(key))); +} + +async function onFetch(event) { + let cachedResponse = null; + if (event.request.method === 'GET') { + // For all navigation requests, try to serve index.html from cache + // If you need some URLs to be server-rendered, edit the following check to exclude those URLs + const shouldServeIndexHtml = event.request.mode === 'navigate'; + + const request = shouldServeIndexHtml ? 'index.html' : event.request; + const cache = await caches.open(cacheName); + cachedResponse = await cache.match(request); + } + + return cachedResponse || fetch(event.request); +} diff --git a/Web/wwwroot/web.js b/Web/wwwroot/web.js new file mode 100644 index 0000000..8a9809a --- /dev/null +++ b/Web/wwwroot/web.js @@ -0,0 +1,23 @@ +let handler; + +window.Connection = { + Initialize: function (interop) { + + handler = function () { + interop.invokeMethodAsync("Connection.StatusChanged", navigator.onLine); + } + + window.addEventListener("online", handler); + window.addEventListener("offline", handler); + + handler(navigator.onLine); + }, + Dispose: function () { + + if (handler != null) { + + window.removeEventListener("online", handler); + window.removeEventListener("offline", handler); + } + } +};