mirror of
https://github.com/fiodarsazanavets/aspire-13-examples.git
synced 2026-06-20 12:23:14 +00:00
Add Azure storage examples
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace OnlineShop.ApiService
|
||||
{
|
||||
public static class AuthExtensions
|
||||
{
|
||||
public static void ConfigureApiJwt(this AuthenticationBuilder authentication)
|
||||
{
|
||||
// Named options
|
||||
authentication.Services
|
||||
.AddOptions<JwtBearerOptions>(
|
||||
JwtBearerDefaults.AuthenticationScheme)
|
||||
.Configure<
|
||||
IConfiguration,
|
||||
IHttpClientFactory,
|
||||
IHostEnvironment>(Configure);
|
||||
|
||||
// Unnamed options
|
||||
authentication.Services.AddOptions<JwtBearerOptions>()
|
||||
.Configure<
|
||||
IConfiguration,
|
||||
IHttpClientFactory,
|
||||
IHostEnvironment>(Configure);
|
||||
|
||||
static void Configure(
|
||||
JwtBearerOptions options,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHostEnvironment hostEnvironment)
|
||||
{
|
||||
var backchannelHttpClient =
|
||||
httpClientFactory.CreateClient(
|
||||
"OidcBackchannel");
|
||||
|
||||
options.Backchannel = backchannelHttpClient;
|
||||
options.Authority =
|
||||
backchannelHttpClient
|
||||
.GetIdpAuthorityUri().ToString();
|
||||
options.RequireHttpsMetadata =
|
||||
!hostEnvironment.IsDevelopment();
|
||||
options.MapInboundClaims = false;
|
||||
options.TokenValidationParameters =
|
||||
new TokenValidationParameters
|
||||
{
|
||||
ValidateAudience = false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OnlineShop.ApiService.Model;
|
||||
|
||||
public class Product
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2100)]
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public decimal Price { get; set; }
|
||||
|
||||
public DateTime DateAdded { get; set; }
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
|
||||
namespace OnlineShop.ApiService.Model;
|
||||
|
||||
public sealed class ProductMetadataEntity : ITableEntity
|
||||
{
|
||||
// Required by Table Storage
|
||||
public string PartitionKey { get; set; } = default!;
|
||||
public string RowKey { get; set; } = default!;
|
||||
|
||||
public DateTimeOffset? Timestamp { get; set; }
|
||||
public ETag ETag { get; set; }
|
||||
|
||||
// Business metadata
|
||||
public bool ReviewsEnabled { get; set; }
|
||||
public bool Featured { get; set; }
|
||||
public int MaxReviewsPerUser { get; set; }
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace OnlineShop.ApiService.Model;
|
||||
|
||||
public sealed class ProductReviewsDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; init; } = default!;
|
||||
|
||||
// Matches Product.Id from SQL Server
|
||||
public int ProductId { get; init; }
|
||||
|
||||
public double AverageRating { get; set; }
|
||||
|
||||
public int TotalReviews { get; set; }
|
||||
|
||||
public List<ProductReview> Reviews { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ProductReview
|
||||
{
|
||||
public string UserId { get; init; } = default!;
|
||||
|
||||
public int Rating { get; init; }
|
||||
|
||||
public string Comment { get; init; } = default!;
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
public bool VerifiedPurchase { get; init; }
|
||||
|
||||
public ReviewStatus Status { get; init; } = ReviewStatus.Approved;
|
||||
}
|
||||
|
||||
public enum ReviewStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Approved = 1,
|
||||
Rejected = 2
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OnlineShop.ApiService.Model;
|
||||
|
||||
public sealed class ProductSpecCsvRow
|
||||
{
|
||||
public int ProductId { get; set; }
|
||||
public string Category { get; set; } = default!;
|
||||
public int WarrantyMonths { get; set; }
|
||||
|
||||
public bool ReviewsEnabled { get; set; }
|
||||
public bool Featured { get; set; }
|
||||
public int MaxReviewsPerUser { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OnlineShop.ServiceDefaults\OnlineShop.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Azure.Data.Tables" Version="13.1.0" />
|
||||
<PackageReference Include="Aspire.Microsoft.Data.SqlClient" Version="13.1.0" />
|
||||
<PackageReference Include="Aspire.MongoDB.Driver" Version="13.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@ApiService_HostAddress = http://localhost:5579
|
||||
|
||||
GET {{ApiService_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,316 @@
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MongoDB.Driver;
|
||||
using OnlineShop.ApiService;
|
||||
using OnlineShop.ApiService.Model;
|
||||
using OnlineShop.ServiceDefaults.Dtos;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services.AddHttpClient(
|
||||
"OidcBackchannel", o => o.BaseAddress = new("http://idp"));
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme =
|
||||
JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme =
|
||||
JwtBearerDefaults.AuthenticationScheme;
|
||||
|
||||
})
|
||||
.AddJwtBearer()
|
||||
.ConfigureApiJwt();
|
||||
|
||||
builder.AddSqlServerClient("sqldb");
|
||||
builder.AddMongoDBClient("mongodb");
|
||||
builder.AddAzureTableServiceClient("tables");
|
||||
builder.AddAzureBlobServiceClient("blobs");
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var connection = scope.ServiceProvider.GetRequiredService<SqlConnection>();
|
||||
connection.Open();
|
||||
|
||||
var createDbCommand = new SqlCommand(@"
|
||||
IF NOT EXISTS (SELECT *
|
||||
FROM sys.databases
|
||||
WHERE name = 'Shop')
|
||||
BEGIN
|
||||
CREATE DATABASE Shop;
|
||||
END;", connection);
|
||||
|
||||
createDbCommand.ExecuteNonQuery();
|
||||
|
||||
var createTableCommand = new SqlCommand(@"
|
||||
USE Shop;
|
||||
IF NOT EXISTS (SELECT *
|
||||
FROM sysobjects
|
||||
WHERE name='Products' and xtype='U')
|
||||
CREATE TABLE Products (
|
||||
Id INT PRIMARY KEY IDENTITY,
|
||||
Title VARCHAR(100) NOT NULL,
|
||||
Summary NVARCHAR(2100) NOT NULL,
|
||||
Price DECIMAL(18,2) NOT NULL,
|
||||
DateAdded DATE NOT NULL,
|
||||
)", connection);
|
||||
|
||||
createTableCommand.ExecuteNonQuery();
|
||||
|
||||
var checkDataCommand =
|
||||
new SqlCommand(
|
||||
"SELECT COUNT(*) FROM Products",
|
||||
connection);
|
||||
|
||||
var count = (int)checkDataCommand
|
||||
.ExecuteScalar();
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
var insertCommand =
|
||||
new SqlCommand("""
|
||||
INSERT INTO Products (Title, Summary, Price, DateAdded)
|
||||
VALUES
|
||||
(
|
||||
'Wireless Optical Mouse',
|
||||
N'Ergonomic wireless optical mouse with adjustable DPI and long battery life, suitable for everyday office and home use.',
|
||||
24.99,
|
||||
'2025-01-05'
|
||||
),
|
||||
(
|
||||
'Mechanical Gaming Keyboard',
|
||||
N'RGB backlit mechanical keyboard with blue switches, anti-ghosting keys, and durable aluminum frame.',
|
||||
129.99,
|
||||
'2025-01-06'
|
||||
),
|
||||
(
|
||||
'27-inch 4K Monitor',
|
||||
N'27-inch UHD 4K monitor with IPS panel, 3840x2160 resolution, HDR support, and ultra-thin bezels.',
|
||||
399.00,
|
||||
'2025-01-07'
|
||||
),
|
||||
(
|
||||
'USB-C Docking Station',
|
||||
N'Multi-port USB-C docking station with HDMI, DisplayPort, Ethernet, USB 3.0 ports, and 100W power delivery.',
|
||||
179.50,
|
||||
'2025-01-08'
|
||||
),
|
||||
(
|
||||
'External SSD 1TB',
|
||||
N'Portable 1TB external SSD with USB 3.2 Gen 2 support, delivering fast read/write speeds in a compact design.',
|
||||
149.99,
|
||||
'2025-01-09'
|
||||
),
|
||||
(
|
||||
'Noise-Cancelling Headphones',
|
||||
N'Over-ear wireless headphones with active noise cancellation, high-fidelity sound, and 30-hour battery life.',
|
||||
249.00,
|
||||
'2025-01-10'
|
||||
),
|
||||
(
|
||||
'Webcam Full HD 1080p',
|
||||
N'Full HD 1080p webcam with built-in microphone, autofocus, and low-light correction for video conferencing.',
|
||||
69.99,
|
||||
'2025-01-11'
|
||||
),
|
||||
(
|
||||
'Gaming Laptop Backpack',
|
||||
N'Water-resistant backpack designed for gaming laptops up to 17 inches, featuring padded compartments and USB charging port.',
|
||||
59.95,
|
||||
'2025-01-12'
|
||||
),
|
||||
(
|
||||
'Wi-Fi 6 Router',
|
||||
N'Dual-band Wi-Fi 6 router offering high-speed wireless connectivity, improved range, and support for multiple devices.',
|
||||
199.00,
|
||||
'2025-01-13'
|
||||
),
|
||||
(
|
||||
'Portable Laser Printer',
|
||||
N'Compact monochrome laser printer suitable for small offices, offering fast printing speeds and wireless connectivity.',
|
||||
289.99,
|
||||
'2025-01-14'
|
||||
);
|
||||
""",
|
||||
connection);
|
||||
|
||||
insertCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var mongoClient = scope.ServiceProvider
|
||||
.GetRequiredService<IMongoClient>();
|
||||
|
||||
var database = mongoClient
|
||||
.GetDatabase("ShopDB");
|
||||
|
||||
var collection = database
|
||||
.GetCollection<ProductReviewsDocument>("ProductReviews");
|
||||
|
||||
var docCount = collection.CountDocuments(FilterDefinition<ProductReviewsDocument>.Empty);
|
||||
|
||||
if (docCount == 0)
|
||||
{
|
||||
var productReviews = new List<ProductReviewsDocument>();
|
||||
|
||||
foreach (var productId in Enumerable.Range(1, 10))
|
||||
{
|
||||
var reviews = new List<ProductReview>();
|
||||
|
||||
var numberOfReviews = Random.Shared.Next(1, 5);
|
||||
|
||||
for (var i = 0; i < numberOfReviews; i++)
|
||||
{
|
||||
reviews.Add(new ProductReview
|
||||
{
|
||||
UserId = $"user-{Random.Shared.Next(1, 100)}",
|
||||
Rating = Random.Shared.Next(3, 6), // 3–5
|
||||
Comment = "Great product, works exactly as expected.",
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-Random.Shared.Next(1, 30)),
|
||||
VerifiedPurchase = Random.Shared.Next(0, 2) == 1,
|
||||
Status = ReviewStatus.Approved
|
||||
});
|
||||
}
|
||||
|
||||
var averageRating = reviews.Average(r => r.Rating);
|
||||
|
||||
productReviews.Add(new ProductReviewsDocument
|
||||
{
|
||||
ProductId = productId, // matches SQL Products.Id
|
||||
AverageRating = Math.Round(averageRating, 2),
|
||||
TotalReviews = reviews.Count,
|
||||
Reviews = reviews
|
||||
});
|
||||
}
|
||||
|
||||
collection.InsertMany(productReviews);
|
||||
}
|
||||
|
||||
var tableServiceClient =
|
||||
scope.ServiceProvider.GetRequiredService<TableServiceClient>();
|
||||
|
||||
var tableClient = tableServiceClient
|
||||
.GetTableClient("ProductMetadata");
|
||||
|
||||
await tableClient.CreateIfNotExistsAsync();
|
||||
|
||||
var existing = tableClient
|
||||
.Query<ProductMetadataEntity>(x => x.PartitionKey == "Product")
|
||||
.Take(1)
|
||||
.Any();
|
||||
|
||||
if (!existing)
|
||||
{
|
||||
var entities = new List<ProductMetadataEntity>();
|
||||
|
||||
foreach (var productId in Enumerable.Range(1, 10))
|
||||
{
|
||||
entities.Add(new ProductMetadataEntity
|
||||
{
|
||||
PartitionKey = "Product",
|
||||
RowKey = productId.ToString(),
|
||||
|
||||
ReviewsEnabled = true,
|
||||
Featured = productId % 2 == 0,
|
||||
MaxReviewsPerUser = 1
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
await tableClient.AddEntityAsync(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.UseExceptionHandler();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.MapGet("/", () => "API service is running.");
|
||||
|
||||
app.MapGet("/products",
|
||||
([FromServices] SqlConnection connection) =>
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
var command = new SqlCommand(@"
|
||||
USE Shop;
|
||||
SELECT
|
||||
Title,
|
||||
Summary,
|
||||
Price
|
||||
FROM Products", connection);
|
||||
var products = new List<ProductDto>();
|
||||
|
||||
using (var reader = command.ExecuteReader())
|
||||
{
|
||||
while (reader.Read())
|
||||
{
|
||||
products.Add(new ProductDto(
|
||||
Title: reader.GetString(0),
|
||||
Summary: reader.GetString(1),
|
||||
Price: reader.GetDecimal(2)
|
||||
));
|
||||
}
|
||||
|
||||
return products.ToArray();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/product-reviews",
|
||||
([FromServices] IMongoClient mongoClient) =>
|
||||
{
|
||||
var database = mongoClient
|
||||
.GetDatabase("ShopDB");
|
||||
|
||||
var collection = database
|
||||
.GetCollection<ProductReviewsDocument>(
|
||||
"ProductReviews");
|
||||
|
||||
var productReviews = collection
|
||||
.Find(FilterDefinition<ProductReviewsDocument>.Empty)
|
||||
.ToList();
|
||||
|
||||
return productReviews.ToArray();
|
||||
});
|
||||
|
||||
app.MapGet("/product-metadata",
|
||||
async (TableServiceClient tableServiceClient) =>
|
||||
{
|
||||
var tableClient = tableServiceClient
|
||||
.GetTableClient("ProductMetadata");
|
||||
|
||||
var metadata = new List<ProductMetadataEntity>();
|
||||
|
||||
var entities = tableClient
|
||||
.QueryAsync<ProductMetadataEntity>(
|
||||
x => x.PartitionKey == "Product");
|
||||
|
||||
await foreach (var entity in entities)
|
||||
{
|
||||
metadata.Add(entity);
|
||||
}
|
||||
|
||||
return metadata.ToArray();
|
||||
});
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
||||
{
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5579",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7465;http://localhost:5579",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OnlineShop.AppHost.Extensions;
|
||||
using OnlineShop.MailDev.Hosting;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var maildev = builder.AddMailDev("maildev");
|
||||
|
||||
var idp = builder.AddKeycloakContainer(
|
||||
"idp", tag: "23.0")
|
||||
.ImportRealms("Keycloak")
|
||||
.WithExternalHttpEndpoints();
|
||||
|
||||
var cache = builder.AddRedis("cache");
|
||||
|
||||
var sql = builder.AddSqlServer("sql").WithLifetime(ContainerLifetime.Persistent);
|
||||
var sqldb = sql.AddDatabase("sqldb");
|
||||
|
||||
var mongo = builder.AddMongoDB("mongo").WithLifetime(ContainerLifetime.Persistent);
|
||||
var mongodb = mongo.AddDatabase("mongodb");
|
||||
|
||||
var tables = builder.AddAzureStorage("storage")
|
||||
.RunAsEmulator()
|
||||
.AddTables("tables");
|
||||
|
||||
var apiService = builder.AddProject<Projects.OnlineShop_ApiService>("apiservice")
|
||||
.WithHttpHealthCheck("/health")
|
||||
.WithReference(tables)
|
||||
.WaitFor(tables)
|
||||
.WithReference(mongodb)
|
||||
.WaitFor(mongodb)
|
||||
.WithReference(idp)
|
||||
.WaitFor(idp)
|
||||
.WaitFor(sqldb)
|
||||
.WithReference(sqldb);
|
||||
|
||||
var webFrontend = builder
|
||||
.AddProject<Projects.OnlineShop_Web>("webfrontend")
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithHttpHealthCheck("/health")
|
||||
.WithReference(apiService)
|
||||
.WaitFor(apiService)
|
||||
.WithReference(idp, env: "Identity__ClientSecret")
|
||||
.WaitFor(idp)
|
||||
.WithReference(cache)
|
||||
.WaitFor(cache);
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
var webAppHttp = webFrontend.GetEndpoint("http");
|
||||
var webAppHttps = webFrontend.GetEndpoint("https");
|
||||
|
||||
idp.WithEnvironment("WEBAPP_HTTP", () =>
|
||||
$"{webAppHttp.Scheme}://{webAppHttp.Host}:{webAppHttp.Port}");
|
||||
|
||||
if (webAppHttps.Exists)
|
||||
{
|
||||
idp.WithEnvironment("WEBAPP_HTTP_CONTAINERHOST",
|
||||
webAppHttps);
|
||||
idp.WithEnvironment("WEBAPP_HTTPS", () =>
|
||||
$"{webAppHttps.Scheme}://{webAppHttps.Host}:{webAppHttps.Port}");
|
||||
}
|
||||
else
|
||||
{
|
||||
idp.WithEnvironment("WEBAPP_HTTP_CONTAINERHOST",
|
||||
webAppHttp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
builder.Build().Run();
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
namespace OnlineShop.AppHost.Extensions;
|
||||
|
||||
internal static class KeycloakHostingExtensions
|
||||
{
|
||||
public static IResourceBuilder<TResource> WithReference<TResource>(
|
||||
this IResourceBuilder<TResource> builder,
|
||||
IResourceBuilder<KeycloakResource> keycloakBuilder,
|
||||
string env) where TResource : IResourceWithEnvironment
|
||||
{
|
||||
builder.WithReference(keycloakBuilder);
|
||||
builder.WithEnvironment(
|
||||
env, keycloakBuilder.Resource.ClientSecret);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<KeycloakResource> AddKeycloakContainer(
|
||||
this IDistributedApplicationBuilder builder,
|
||||
string name,
|
||||
int? port = null,
|
||||
string? tag = null)
|
||||
{
|
||||
var keycloakContainer = new KeycloakResource(name)
|
||||
{
|
||||
ClientSecret = "some_secret"
|
||||
};
|
||||
|
||||
var keycloak = builder.AddResource(keycloakContainer)
|
||||
.WithAnnotation(new ContainerImageAnnotation
|
||||
{
|
||||
Registry = "quay.io",
|
||||
Image = "keycloak/keycloak",
|
||||
Tag = tag ?? "latest"
|
||||
})
|
||||
.WithHttpEndpoint(port: port, targetPort: 8080)
|
||||
.WithEnvironment("KEYCLOAK_ADMIN", "admin")
|
||||
.WithEnvironment("KEYCLOAK_ADMIN_PASSWORD", "admin")
|
||||
.WithEnvironment("WEBAPP_CLIENT_SECRET", keycloakContainer.ClientSecret);
|
||||
|
||||
if (builder.ExecutionContext.IsRunMode)
|
||||
{
|
||||
keycloak.WithArgs("start-dev");
|
||||
}
|
||||
else
|
||||
{
|
||||
keycloak.WithArgs("start");
|
||||
}
|
||||
|
||||
return keycloak;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<KeycloakResource>
|
||||
ImportRealms(this IResourceBuilder<KeycloakResource>
|
||||
builder, string source)
|
||||
{
|
||||
builder
|
||||
.WithBindMount(source,
|
||||
"/opt/keycloak/data/import")
|
||||
.WithAnnotation(
|
||||
new CommandLineArgsCallbackAnnotation(
|
||||
args =>
|
||||
{
|
||||
args.Clear();
|
||||
if (builder.ApplicationBuilder
|
||||
.ExecutionContext.IsRunMode)
|
||||
{
|
||||
args.Add("start-dev");
|
||||
}
|
||||
else
|
||||
{
|
||||
args.Add("start");
|
||||
}
|
||||
args.Add("--import-realm");
|
||||
}));
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
internal class KeycloakResource(string name) :
|
||||
ContainerResource(name),
|
||||
IResourceWithServiceDiscovery
|
||||
{
|
||||
public string? ClientSecret { get; set; }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.0.1">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>2f2ef062-99af-422f-b6a5-1094759553e7</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.Azure.Storage" Version="13.1.0" />
|
||||
<PackageReference Include="Aspire.Hosting.MongoDB" Version="13.1.0" />
|
||||
<PackageReference Include="Aspire.Hosting.Redis" Version="13.0.1" />
|
||||
<PackageReference Include="Aspire.Hosting.SqlServer" Version="13.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OnlineShop.ApiService\OnlineShop.ApiService.csproj" />
|
||||
<ProjectReference Include="..\OnlineShop.Web\OnlineShop.Web.csproj" />
|
||||
<ProjectReference Include="..\OnlineShop.MailDev.Hosting\OnlineShop.MailDev.Hosting.csproj" IsAspireProjectResource="false" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Keycloak\import.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:17233;http://localhost:15066",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21152",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23107",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22167"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:15066",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19114",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18044",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20232"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Aspire.Hosting.Dcp": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
namespace OnlineShop.MailDev.Hosting;
|
||||
|
||||
internal static class MailDevContainerImageTags
|
||||
{
|
||||
internal const string Registry = "docker.io";
|
||||
|
||||
internal const string Image = "maildev/maildev";
|
||||
|
||||
internal const string Tag = "2.1.0";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
|
||||
namespace OnlineShop.MailDev.Hosting;
|
||||
|
||||
public sealed class MailDevResource(string name) :
|
||||
ContainerResource(name), IResourceWithConnectionString
|
||||
{
|
||||
internal const string SmtpEndpointName = "smtp";
|
||||
internal const string HttpEndpointName = "http";
|
||||
|
||||
private EndpointReference? _smtpReference;
|
||||
|
||||
public EndpointReference SmtpEndpoint =>
|
||||
_smtpReference ??= new(this, SmtpEndpointName);
|
||||
|
||||
public ReferenceExpression ConnectionStringExpression =>
|
||||
ReferenceExpression.Create(
|
||||
$"smtp://{SmtpEndpoint.Property(EndpointProperty.Host)}:{SmtpEndpoint.Property(EndpointProperty.Port)}"
|
||||
);
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using Aspire.Hosting;
|
||||
using Aspire.Hosting.ApplicationModel;
|
||||
|
||||
namespace OnlineShop.MailDev.Hosting;
|
||||
|
||||
public static class MailDevResourceBuilderExtensions
|
||||
{
|
||||
public static IResourceBuilder<MailDevResource> AddMailDev(
|
||||
this IDistributedApplicationBuilder builder,
|
||||
string name,
|
||||
int? httpPort = null,
|
||||
int? smtpPort = null)
|
||||
{
|
||||
MailDevResource resource = new(name);
|
||||
|
||||
return builder.AddResource(resource)
|
||||
.WithImage(MailDevContainerImageTags.Image)
|
||||
.WithImageRegistry(MailDevContainerImageTags.Registry)
|
||||
.WithImageTag(MailDevContainerImageTags.Tag)
|
||||
.WithHttpEndpoint(
|
||||
targetPort: 1080,
|
||||
port: httpPort,
|
||||
name: MailDevResource.HttpEndpointName)
|
||||
.WithEndpoint(
|
||||
targetPort: 1025,
|
||||
port: smtpPort,
|
||||
name: MailDevResource.SmtpEndpointName);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting" Version="13.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OnlineShop.ServiceDefaults.Dtos;
|
||||
|
||||
public record ProductDto(
|
||||
string Title,
|
||||
string Summary,
|
||||
decimal Price
|
||||
);
|
||||
@@ -0,0 +1,136 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.ServiceDiscovery;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
|
||||
// This project should be referenced by each service project in your solution.
|
||||
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
|
||||
public static class Extensions
|
||||
{
|
||||
private const string HealthEndpointPath = "/health";
|
||||
private const string AlivenessEndpointPath = "/alive";
|
||||
|
||||
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.ConfigureOpenTelemetry();
|
||||
|
||||
builder.AddDefaultHealthChecks();
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
|
||||
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||
{
|
||||
// Turn on resilience by default
|
||||
http.AddStandardResilienceHandler();
|
||||
|
||||
// Turn on service discovery by default
|
||||
http.AddServiceDiscovery();
|
||||
});
|
||||
|
||||
// Uncomment the following to restrict the allowed schemes for service discovery.
|
||||
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
|
||||
// {
|
||||
// options.AllowedSchemes = ["https"];
|
||||
// });
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
})
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddSource(builder.Environment.ApplicationName)
|
||||
.AddAspNetCoreInstrumentation(tracing =>
|
||||
// Exclude health check requests from tracing
|
||||
tracing.Filter = context =>
|
||||
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
|
||||
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
|
||||
)
|
||||
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
|
||||
//.AddGrpcClientInstrumentation()
|
||||
.AddHttpClientInstrumentation();
|
||||
});
|
||||
|
||||
builder.AddOpenTelemetryExporters();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
|
||||
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
|
||||
//{
|
||||
// builder.Services.AddOpenTelemetry()
|
||||
// .UseAzureMonitor();
|
||||
//}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
// Add a default liveness check to ensure app is responsive
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Adding health checks endpoints to applications in non-development environments has security implications.
|
||||
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
// All health checks must pass for app to be considered ready to accept traffic after starting
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
|
||||
// Only health checks tagged with the "live" tag must pass for app to be considered alive
|
||||
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
public static Uri GetIdpAuthorityUri(this HttpClient httpClient)
|
||||
{
|
||||
var idpBaseUri = httpClient.BaseAddress
|
||||
?? throw new InvalidOperationException(
|
||||
$"HttpClient instance does not have a BaseAddress configured.");
|
||||
return new Uri(idpBaseUri, "realms/OnlineShop/");
|
||||
}
|
||||
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
|
||||
namespace OnlineShop.Web;
|
||||
|
||||
public static class AuthExtensions
|
||||
{
|
||||
public static void ConfigureWebAppOpenIdConnect(this AuthenticationBuilder authentication)
|
||||
{
|
||||
// Named options
|
||||
authentication.Services
|
||||
.AddOptions<OpenIdConnectOptions>(
|
||||
OpenIdConnectDefaults.AuthenticationScheme)
|
||||
.Configure<
|
||||
IConfiguration,
|
||||
IHttpClientFactory,
|
||||
IHostEnvironment>(Configure);
|
||||
|
||||
// Unnamed options
|
||||
authentication.Services.AddOptions<OpenIdConnectOptions>()
|
||||
.Configure<
|
||||
IConfiguration,
|
||||
IHttpClientFactory,
|
||||
IHostEnvironment>(Configure);
|
||||
|
||||
static void Configure(
|
||||
OpenIdConnectOptions options,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHostEnvironment hostEnvironment)
|
||||
{
|
||||
var backchannelHttpClient =
|
||||
httpClientFactory.CreateClient(
|
||||
"OidcBackchannel");
|
||||
|
||||
options.Backchannel = backchannelHttpClient;
|
||||
options.Authority =
|
||||
backchannelHttpClient
|
||||
.GetIdpAuthorityUri().ToString();
|
||||
options.ClientId = "webapp";
|
||||
options.ClientSecret =
|
||||
Environment
|
||||
.GetEnvironmentVariable(
|
||||
"Identity__ClientSecret");
|
||||
options.ResponseType =
|
||||
OpenIdConnectResponseType.Code;
|
||||
options.SaveTokens = true;
|
||||
options.RequireHttpsMetadata =
|
||||
!hostEnvironment.IsDevelopment();
|
||||
options.MapInboundClaims = false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["OnlineShop.Web.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">OnlineShop</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill" aria-hidden="true"></span> Home
|
||||
</NavLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep a {
|
||||
color: #d7d7d7;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep a:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@requestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
public HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? requestId;
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(requestId);
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
requestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@page "/"
|
||||
@using OnlineShop.ServiceDefaults.Dtos
|
||||
@attribute [StreamRendering(true)]
|
||||
@attribute [OutputCache(Duration = 5)]
|
||||
|
||||
@inject ProductsApiClient ProductsApi
|
||||
|
||||
<PageTitle>Products</PageTitle>
|
||||
|
||||
<h1>Products</h1>
|
||||
|
||||
@if (products == null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Price (USD)</th>
|
||||
<th>Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var product in products)
|
||||
{
|
||||
<tr>
|
||||
<td>@product.Title</td>
|
||||
<td>@product.Price</td>
|
||||
<td>@product.Summary</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private ProductDto[]? products;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
products = await ProductsApi.GetProductsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,11 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.OutputCaching
|
||||
@using Microsoft.JSInterop
|
||||
@using OnlineShop.Web
|
||||
@using OnlineShop.Web.Components
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.StackExchange.Redis.OutputCaching" Version="13.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OnlineShop.ServiceDefaults\OnlineShop.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,26 @@
|
||||
using OnlineShop.ServiceDefaults.Dtos;
|
||||
|
||||
namespace OnlineShop.Web;
|
||||
|
||||
public class ProductsApiClient(HttpClient httpClient)
|
||||
{
|
||||
public async Task<ProductDto[]> GetProductsAsync(int maxItems = 10, CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<ProductDto>? products = null;
|
||||
|
||||
await foreach (var product in httpClient.GetFromJsonAsAsyncEnumerable<ProductDto>("/products", cancellationToken))
|
||||
{
|
||||
if (products?.Count >= maxItems)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (product is not null)
|
||||
{
|
||||
products ??= [];
|
||||
products.Add(product);
|
||||
}
|
||||
}
|
||||
|
||||
return products?.ToArray() ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using OnlineShop.Web;
|
||||
using OnlineShop.Web.Components;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddRedisOutputCache("cache");
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
builder.Services.AddOutputCache();
|
||||
|
||||
builder.Services.AddHttpClient(
|
||||
"OidcBackchannel", o => o.BaseAddress = new("http://idp"));
|
||||
|
||||
builder.Services.AddHttpClient<ProductsApiClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new("https+http://apiservice");
|
||||
});
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme =
|
||||
CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme =
|
||||
OpenIdConnectDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddOpenIdConnect()
|
||||
.ConfigureWebAppOpenIdConnect();
|
||||
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.UseOutputCache();
|
||||
|
||||
app.MapStaticAssets();
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5240",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7199;http://localhost:5240",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
html, body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
a, .btn-link {
|
||||
color: #006bb7;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e52400;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e52400;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||
text-align: start;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Vendored
+4085
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+4084
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+597
@@ -0,0 +1,597 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: left;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* rtl:raw:
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
*/
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+594
@@ -0,0 +1,594 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: right;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: right;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
||||
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+5402
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+5393
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+12057
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+12030
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+6314
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+4447
File diff suppressed because it is too large
Load Diff
AppWithAzureBlobStorage/OnlineShop/OnlineShop.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map
Vendored
+1
File diff suppressed because one or more lines are too long
AppWithAzureBlobStorage/OnlineShop/OnlineShop.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js
Vendored
+7
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+4494
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+7
File diff suppressed because one or more lines are too long
AppWithAzureBlobStorage/OnlineShop/OnlineShop.Web/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
<Solution>
|
||||
<Project Path="OnlineShop.ApiService/OnlineShop.ApiService.csproj" />
|
||||
<Project Path="OnlineShop.AppHost/OnlineShop.AppHost.csproj" />
|
||||
<Project Path="OnlineShop.MailDev.Hosting/OnlineShop.MailDev.Hosting.csproj" Id="bcd7a762-a5a4-4043-9990-a00cca6b4a91" />
|
||||
<Project Path="OnlineShop.ServiceDefaults/OnlineShop.ServiceDefaults.csproj" />
|
||||
<Project Path="OnlineShop.Web/OnlineShop.Web.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace OnlineShop.ApiService
|
||||
{
|
||||
public static class AuthExtensions
|
||||
{
|
||||
public static void ConfigureApiJwt(this AuthenticationBuilder authentication)
|
||||
{
|
||||
// Named options
|
||||
authentication.Services
|
||||
.AddOptions<JwtBearerOptions>(
|
||||
JwtBearerDefaults.AuthenticationScheme)
|
||||
.Configure<
|
||||
IConfiguration,
|
||||
IHttpClientFactory,
|
||||
IHostEnvironment>(Configure);
|
||||
|
||||
// Unnamed options
|
||||
authentication.Services.AddOptions<JwtBearerOptions>()
|
||||
.Configure<
|
||||
IConfiguration,
|
||||
IHttpClientFactory,
|
||||
IHostEnvironment>(Configure);
|
||||
|
||||
static void Configure(
|
||||
JwtBearerOptions options,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IHostEnvironment hostEnvironment)
|
||||
{
|
||||
var backchannelHttpClient =
|
||||
httpClientFactory.CreateClient(
|
||||
"OidcBackchannel");
|
||||
|
||||
options.Backchannel = backchannelHttpClient;
|
||||
options.Authority =
|
||||
backchannelHttpClient
|
||||
.GetIdpAuthorityUri().ToString();
|
||||
options.RequireHttpsMetadata =
|
||||
!hostEnvironment.IsDevelopment();
|
||||
options.MapInboundClaims = false;
|
||||
options.TokenValidationParameters =
|
||||
new TokenValidationParameters
|
||||
{
|
||||
ValidateAudience = false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace OnlineShop.ApiService.Model;
|
||||
|
||||
public class Product
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2100)]
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
public decimal Price { get; set; }
|
||||
|
||||
public DateTime DateAdded { get; set; }
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
|
||||
namespace OnlineShop.ApiService.Model;
|
||||
|
||||
public sealed class ProductMetadataEntity : ITableEntity
|
||||
{
|
||||
// Required by Table Storage
|
||||
public string PartitionKey { get; set; } = default!;
|
||||
public string RowKey { get; set; } = default!;
|
||||
|
||||
public DateTimeOffset? Timestamp { get; set; }
|
||||
public ETag ETag { get; set; }
|
||||
|
||||
// Business metadata
|
||||
public bool ReviewsEnabled { get; set; }
|
||||
public bool Featured { get; set; }
|
||||
public int MaxReviewsPerUser { get; set; }
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace OnlineShop.ApiService.Model;
|
||||
|
||||
public sealed class ProductReviewsDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; init; } = default!;
|
||||
|
||||
// Matches Product.Id from SQL Server
|
||||
public int ProductId { get; init; }
|
||||
|
||||
public double AverageRating { get; set; }
|
||||
|
||||
public int TotalReviews { get; set; }
|
||||
|
||||
public List<ProductReview> Reviews { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ProductReview
|
||||
{
|
||||
public string UserId { get; init; } = default!;
|
||||
|
||||
public int Rating { get; init; }
|
||||
|
||||
public string Comment { get; init; } = default!;
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
public bool VerifiedPurchase { get; init; }
|
||||
|
||||
public ReviewStatus Status { get; init; } = ReviewStatus.Approved;
|
||||
}
|
||||
|
||||
public enum ReviewStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Approved = 1,
|
||||
Rejected = 2
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OnlineShop.ServiceDefaults\OnlineShop.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Azure.Data.Tables" Version="13.1.0" />
|
||||
<PackageReference Include="Aspire.Microsoft.Data.SqlClient" Version="13.1.0" />
|
||||
<PackageReference Include="Aspire.MongoDB.Driver" Version="13.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@ApiService_HostAddress = http://localhost:5579
|
||||
|
||||
GET {{ApiService_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,315 @@
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MongoDB.Driver;
|
||||
using OnlineShop.ApiService;
|
||||
using OnlineShop.ApiService.Model;
|
||||
using OnlineShop.ServiceDefaults.Dtos;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services.AddHttpClient(
|
||||
"OidcBackchannel", o => o.BaseAddress = new("http://idp"));
|
||||
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme =
|
||||
JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme =
|
||||
JwtBearerDefaults.AuthenticationScheme;
|
||||
|
||||
})
|
||||
.AddJwtBearer()
|
||||
.ConfigureApiJwt();
|
||||
|
||||
builder.AddSqlServerClient("sqldb");
|
||||
builder.AddMongoDBClient("mongodb");
|
||||
builder.AddAzureTableServiceClient("tables");
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var connection = scope.ServiceProvider.GetRequiredService<SqlConnection>();
|
||||
connection.Open();
|
||||
|
||||
var createDbCommand = new SqlCommand(@"
|
||||
IF NOT EXISTS (SELECT *
|
||||
FROM sys.databases
|
||||
WHERE name = 'Shop')
|
||||
BEGIN
|
||||
CREATE DATABASE Shop;
|
||||
END;", connection);
|
||||
|
||||
createDbCommand.ExecuteNonQuery();
|
||||
|
||||
var createTableCommand = new SqlCommand(@"
|
||||
USE Shop;
|
||||
IF NOT EXISTS (SELECT *
|
||||
FROM sysobjects
|
||||
WHERE name='Products' and xtype='U')
|
||||
CREATE TABLE Products (
|
||||
Id INT PRIMARY KEY IDENTITY,
|
||||
Title VARCHAR(100) NOT NULL,
|
||||
Summary NVARCHAR(2100) NOT NULL,
|
||||
Price DECIMAL(18,2) NOT NULL,
|
||||
DateAdded DATE NOT NULL,
|
||||
)", connection);
|
||||
|
||||
createTableCommand.ExecuteNonQuery();
|
||||
|
||||
var checkDataCommand =
|
||||
new SqlCommand(
|
||||
"SELECT COUNT(*) FROM Products",
|
||||
connection);
|
||||
|
||||
var count = (int)checkDataCommand
|
||||
.ExecuteScalar();
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
var insertCommand =
|
||||
new SqlCommand("""
|
||||
INSERT INTO Products (Title, Summary, Price, DateAdded)
|
||||
VALUES
|
||||
(
|
||||
'Wireless Optical Mouse',
|
||||
N'Ergonomic wireless optical mouse with adjustable DPI and long battery life, suitable for everyday office and home use.',
|
||||
24.99,
|
||||
'2025-01-05'
|
||||
),
|
||||
(
|
||||
'Mechanical Gaming Keyboard',
|
||||
N'RGB backlit mechanical keyboard with blue switches, anti-ghosting keys, and durable aluminum frame.',
|
||||
129.99,
|
||||
'2025-01-06'
|
||||
),
|
||||
(
|
||||
'27-inch 4K Monitor',
|
||||
N'27-inch UHD 4K monitor with IPS panel, 3840x2160 resolution, HDR support, and ultra-thin bezels.',
|
||||
399.00,
|
||||
'2025-01-07'
|
||||
),
|
||||
(
|
||||
'USB-C Docking Station',
|
||||
N'Multi-port USB-C docking station with HDMI, DisplayPort, Ethernet, USB 3.0 ports, and 100W power delivery.',
|
||||
179.50,
|
||||
'2025-01-08'
|
||||
),
|
||||
(
|
||||
'External SSD 1TB',
|
||||
N'Portable 1TB external SSD with USB 3.2 Gen 2 support, delivering fast read/write speeds in a compact design.',
|
||||
149.99,
|
||||
'2025-01-09'
|
||||
),
|
||||
(
|
||||
'Noise-Cancelling Headphones',
|
||||
N'Over-ear wireless headphones with active noise cancellation, high-fidelity sound, and 30-hour battery life.',
|
||||
249.00,
|
||||
'2025-01-10'
|
||||
),
|
||||
(
|
||||
'Webcam Full HD 1080p',
|
||||
N'Full HD 1080p webcam with built-in microphone, autofocus, and low-light correction for video conferencing.',
|
||||
69.99,
|
||||
'2025-01-11'
|
||||
),
|
||||
(
|
||||
'Gaming Laptop Backpack',
|
||||
N'Water-resistant backpack designed for gaming laptops up to 17 inches, featuring padded compartments and USB charging port.',
|
||||
59.95,
|
||||
'2025-01-12'
|
||||
),
|
||||
(
|
||||
'Wi-Fi 6 Router',
|
||||
N'Dual-band Wi-Fi 6 router offering high-speed wireless connectivity, improved range, and support for multiple devices.',
|
||||
199.00,
|
||||
'2025-01-13'
|
||||
),
|
||||
(
|
||||
'Portable Laser Printer',
|
||||
N'Compact monochrome laser printer suitable for small offices, offering fast printing speeds and wireless connectivity.',
|
||||
289.99,
|
||||
'2025-01-14'
|
||||
);
|
||||
""",
|
||||
connection);
|
||||
|
||||
insertCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
var mongoClient = scope.ServiceProvider
|
||||
.GetRequiredService<IMongoClient>();
|
||||
|
||||
var database = mongoClient
|
||||
.GetDatabase("ShopDB");
|
||||
|
||||
var collection = database
|
||||
.GetCollection<ProductReviewsDocument>("ProductReviews");
|
||||
|
||||
var docCount = collection.CountDocuments(FilterDefinition<ProductReviewsDocument>.Empty);
|
||||
|
||||
if (docCount == 0)
|
||||
{
|
||||
var productReviews = new List<ProductReviewsDocument>();
|
||||
|
||||
foreach (var productId in Enumerable.Range(1, 10))
|
||||
{
|
||||
var reviews = new List<ProductReview>();
|
||||
|
||||
var numberOfReviews = Random.Shared.Next(1, 5);
|
||||
|
||||
for (var i = 0; i < numberOfReviews; i++)
|
||||
{
|
||||
reviews.Add(new ProductReview
|
||||
{
|
||||
UserId = $"user-{Random.Shared.Next(1, 100)}",
|
||||
Rating = Random.Shared.Next(3, 6), // 3–5
|
||||
Comment = "Great product, works exactly as expected.",
|
||||
CreatedAt = DateTime.UtcNow.AddDays(-Random.Shared.Next(1, 30)),
|
||||
VerifiedPurchase = Random.Shared.Next(0, 2) == 1,
|
||||
Status = ReviewStatus.Approved
|
||||
});
|
||||
}
|
||||
|
||||
var averageRating = reviews.Average(r => r.Rating);
|
||||
|
||||
productReviews.Add(new ProductReviewsDocument
|
||||
{
|
||||
ProductId = productId, // matches SQL Products.Id
|
||||
AverageRating = Math.Round(averageRating, 2),
|
||||
TotalReviews = reviews.Count,
|
||||
Reviews = reviews
|
||||
});
|
||||
}
|
||||
|
||||
collection.InsertMany(productReviews);
|
||||
}
|
||||
|
||||
var tableServiceClient =
|
||||
scope.ServiceProvider.GetRequiredService<TableServiceClient>();
|
||||
|
||||
var tableClient = tableServiceClient
|
||||
.GetTableClient("ProductMetadata");
|
||||
|
||||
await tableClient.CreateIfNotExistsAsync();
|
||||
|
||||
var existing = tableClient
|
||||
.Query<ProductMetadataEntity>(x => x.PartitionKey == "Product")
|
||||
.Take(1)
|
||||
.Any();
|
||||
|
||||
if (!existing)
|
||||
{
|
||||
var entities = new List<ProductMetadataEntity>();
|
||||
|
||||
foreach (var productId in Enumerable.Range(1, 10))
|
||||
{
|
||||
entities.Add(new ProductMetadataEntity
|
||||
{
|
||||
PartitionKey = "Product",
|
||||
RowKey = productId.ToString(),
|
||||
|
||||
ReviewsEnabled = true,
|
||||
Featured = productId % 2 == 0,
|
||||
MaxReviewsPerUser = 1
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
await tableClient.AddEntityAsync(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.UseExceptionHandler();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.MapGet("/", () => "API service is running.");
|
||||
|
||||
app.MapGet("/products",
|
||||
([FromServices] SqlConnection connection) =>
|
||||
{
|
||||
connection.Open();
|
||||
|
||||
var command = new SqlCommand(@"
|
||||
USE Shop;
|
||||
SELECT
|
||||
Title,
|
||||
Summary,
|
||||
Price
|
||||
FROM Products", connection);
|
||||
var products = new List<ProductDto>();
|
||||
|
||||
using (var reader = command.ExecuteReader())
|
||||
{
|
||||
while (reader.Read())
|
||||
{
|
||||
products.Add(new ProductDto(
|
||||
Title: reader.GetString(0),
|
||||
Summary: reader.GetString(1),
|
||||
Price: reader.GetDecimal(2)
|
||||
));
|
||||
}
|
||||
|
||||
return products.ToArray();
|
||||
}
|
||||
});
|
||||
|
||||
app.MapGet("/product-reviews",
|
||||
([FromServices] IMongoClient mongoClient) =>
|
||||
{
|
||||
var database = mongoClient
|
||||
.GetDatabase("ShopDB");
|
||||
|
||||
var collection = database
|
||||
.GetCollection<ProductReviewsDocument>(
|
||||
"ProductReviews");
|
||||
|
||||
var productReviews = collection
|
||||
.Find(FilterDefinition<ProductReviewsDocument>.Empty)
|
||||
.ToList();
|
||||
|
||||
return productReviews.ToArray();
|
||||
});
|
||||
|
||||
app.MapGet("/product-metadata",
|
||||
async (TableServiceClient tableServiceClient) =>
|
||||
{
|
||||
var tableClient = tableServiceClient
|
||||
.GetTableClient("ProductMetadata");
|
||||
|
||||
var metadata = new List<ProductMetadataEntity>();
|
||||
|
||||
var entities = tableClient
|
||||
.QueryAsync<ProductMetadataEntity>(
|
||||
x => x.PartitionKey == "Product");
|
||||
|
||||
await foreach (var entity in entities)
|
||||
{
|
||||
metadata.Add(entity);
|
||||
}
|
||||
|
||||
return metadata.ToArray();
|
||||
});
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
||||
{
|
||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5579",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7465;http://localhost:5579",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OnlineShop.AppHost.Extensions;
|
||||
using OnlineShop.MailDev.Hosting;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var maildev = builder.AddMailDev("maildev");
|
||||
|
||||
var idp = builder.AddKeycloakContainer(
|
||||
"idp", tag: "23.0")
|
||||
.ImportRealms("Keycloak")
|
||||
.WithExternalHttpEndpoints();
|
||||
|
||||
var cache = builder.AddRedis("cache");
|
||||
|
||||
var sql = builder.AddSqlServer("sql").WithLifetime(ContainerLifetime.Persistent);
|
||||
var sqldb = sql.AddDatabase("sqldb");
|
||||
|
||||
var mongo = builder.AddMongoDB("mongo").WithLifetime(ContainerLifetime.Persistent);
|
||||
var mongodb = mongo.AddDatabase("mongodb");
|
||||
|
||||
var tables = builder.AddAzureStorage("storage")
|
||||
.RunAsEmulator()
|
||||
.AddTables("tables");
|
||||
|
||||
var apiService = builder.AddProject<Projects.OnlineShop_ApiService>("apiservice")
|
||||
.WithHttpHealthCheck("/health")
|
||||
.WithReference(tables)
|
||||
.WaitFor(tables)
|
||||
.WithReference(mongodb)
|
||||
.WaitFor(mongodb)
|
||||
.WithReference(idp)
|
||||
.WaitFor(idp)
|
||||
.WaitFor(sqldb)
|
||||
.WithReference(sqldb);
|
||||
|
||||
var webFrontend = builder
|
||||
.AddProject<Projects.OnlineShop_Web>("webfrontend")
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithHttpHealthCheck("/health")
|
||||
.WithReference(apiService)
|
||||
.WaitFor(apiService)
|
||||
.WithReference(idp, env: "Identity__ClientSecret")
|
||||
.WaitFor(idp)
|
||||
.WithReference(cache)
|
||||
.WaitFor(cache);
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
var webAppHttp = webFrontend.GetEndpoint("http");
|
||||
var webAppHttps = webFrontend.GetEndpoint("https");
|
||||
|
||||
idp.WithEnvironment("WEBAPP_HTTP", () =>
|
||||
$"{webAppHttp.Scheme}://{webAppHttp.Host}:{webAppHttp.Port}");
|
||||
|
||||
if (webAppHttps.Exists)
|
||||
{
|
||||
idp.WithEnvironment("WEBAPP_HTTP_CONTAINERHOST",
|
||||
webAppHttps);
|
||||
idp.WithEnvironment("WEBAPP_HTTPS", () =>
|
||||
$"{webAppHttps.Scheme}://{webAppHttps.Host}:{webAppHttps.Port}");
|
||||
}
|
||||
else
|
||||
{
|
||||
idp.WithEnvironment("WEBAPP_HTTP_CONTAINERHOST",
|
||||
webAppHttp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
builder.Build().Run();
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
namespace OnlineShop.AppHost.Extensions;
|
||||
|
||||
internal static class KeycloakHostingExtensions
|
||||
{
|
||||
public static IResourceBuilder<TResource> WithReference<TResource>(
|
||||
this IResourceBuilder<TResource> builder,
|
||||
IResourceBuilder<KeycloakResource> keycloakBuilder,
|
||||
string env) where TResource : IResourceWithEnvironment
|
||||
{
|
||||
builder.WithReference(keycloakBuilder);
|
||||
builder.WithEnvironment(
|
||||
env, keycloakBuilder.Resource.ClientSecret);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<KeycloakResource> AddKeycloakContainer(
|
||||
this IDistributedApplicationBuilder builder,
|
||||
string name,
|
||||
int? port = null,
|
||||
string? tag = null)
|
||||
{
|
||||
var keycloakContainer = new KeycloakResource(name)
|
||||
{
|
||||
ClientSecret = "some_secret"
|
||||
};
|
||||
|
||||
var keycloak = builder.AddResource(keycloakContainer)
|
||||
.WithAnnotation(new ContainerImageAnnotation
|
||||
{
|
||||
Registry = "quay.io",
|
||||
Image = "keycloak/keycloak",
|
||||
Tag = tag ?? "latest"
|
||||
})
|
||||
.WithHttpEndpoint(port: port, targetPort: 8080)
|
||||
.WithEnvironment("KEYCLOAK_ADMIN", "admin")
|
||||
.WithEnvironment("KEYCLOAK_ADMIN_PASSWORD", "admin")
|
||||
.WithEnvironment("WEBAPP_CLIENT_SECRET", keycloakContainer.ClientSecret);
|
||||
|
||||
if (builder.ExecutionContext.IsRunMode)
|
||||
{
|
||||
keycloak.WithArgs("start-dev");
|
||||
}
|
||||
else
|
||||
{
|
||||
keycloak.WithArgs("start");
|
||||
}
|
||||
|
||||
return keycloak;
|
||||
}
|
||||
|
||||
public static IResourceBuilder<KeycloakResource>
|
||||
ImportRealms(this IResourceBuilder<KeycloakResource>
|
||||
builder, string source)
|
||||
{
|
||||
builder
|
||||
.WithBindMount(source,
|
||||
"/opt/keycloak/data/import")
|
||||
.WithAnnotation(
|
||||
new CommandLineArgsCallbackAnnotation(
|
||||
args =>
|
||||
{
|
||||
args.Clear();
|
||||
if (builder.ApplicationBuilder
|
||||
.ExecutionContext.IsRunMode)
|
||||
{
|
||||
args.Add("start-dev");
|
||||
}
|
||||
else
|
||||
{
|
||||
args.Add("start");
|
||||
}
|
||||
args.Add("--import-realm");
|
||||
}));
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
internal class KeycloakResource(string name) :
|
||||
ContainerResource(name),
|
||||
IResourceWithServiceDiscovery
|
||||
{
|
||||
public string? ClientSecret { get; set; }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user