Skip to content

Public Web

Cargonerds.Web.Public is the public-facing marketing / content website of the solution. It is a standalone ASP.NET Core MVC + Razor Pages application (not Blazor and not the Next.js realtime frontend) built on the ABP Framework, using the LeptonX MVC theme and the CMS Kit Pro public web module.

It is a thin presentation host: it has no DbContext, no domain or application layer and no EF Core. Every piece of data it needs is fetched remotely over HTTP through ABP's dynamic C# client proxies against Cargonerds.HttpApi.Host (the RemoteServices:Default endpoint) and the AuthServer (RemoteServices:AbpAccountPublic). This is the classic ABP MVC client / tiered front-end pattern.

What the site actually serves today

The content is essentially the stock ABP "public website" startup template, lightly branded — a placeholder landing page, a sample article, a contact page and a privacy page. It is wired and deployable, but not yet a finished marketing site. See Content & pages and Gotchas.

The single most important operational fact: Web.Public is deployed standalone via its own Helm chart and Dockerfile, and is NOT a node in the .NET Aspire AppHost graph. See Orchestration status.

Tech stack

Concern What is used
Framework ASP.NET Core MVC + Razor Pages (Microsoft.NET.Sdk.Web, net10.0, InProcess hosting)
Theme ABP LeptonX MVC theme (AbpAspNetCoreMvcUiLeptonXThemeModule)
Content CMS Kit Pro public web (CmsKitProPublicWebModule) — commenting, contact form
Authentication OpenID Connect to Cargonerds.AuthServer (AbpAspNetCoreAuthenticationOpenIdConnectModule)
API access Cargonerds.HttpApi.Client dynamic proxies (AbpHttpClientWebModule + ...IdentityModel.Web)
Caching / locking Redis (distributed cache + Medallion distributed lock)
Logging Serilog (AbpAspNetCoreSerilogModule) with file, console and OpenTelemetry sinks
reCAPTCHA Owl.reCAPTCHA (v3)
Branding CargonerdsBrandingProvider over WhiteLabelingOptions

Module wiring lives in src/Cargonerds.Web.Public/CargonerdsWebPublicModule.cs; host bootstrap in src/Cargonerds.Web.Public/Program.cs. The project file src/Cargonerds.Web.Public/Cargonerds.Web.Public.csproj references only Cargonerds.ServiceDefaults, Cargonerds.HttpApi.Client, Cargonerds.HttpApi and Cargonerds.UI.Shared — i.e. contracts and HTTP clients, never the domain or EF Core projects.

The ABP module

CargonerdsWebPublicModule is an ABP module (: AbpModule) whose [DependsOn(...)] graph composes the whole application from framework and product modules:

[DependsOn(
    typeof(AbpAutofacModule),
    typeof(AbpCachingStackExchangeRedisModule),
    typeof(AbpDistributedLockingModule),
    typeof(AbpAspNetCoreSerilogModule),
    typeof(AbpStudioClientAspNetCoreModule),
    typeof(AbpAspNetCoreMvcUiLeptonXThemeModule),
    typeof(CargonerdsHttpApiClientModule),
    typeof(CmsKitProPublicWebModule),
    typeof(CargonerdsHttpApiModule),
    typeof(AbpAspNetCoreAuthenticationOpenIdConnectModule),
    typeof(AbpAspNetCoreMvcClientModule),
    typeof(AbpHttpClientWebModule),
    typeof(AbpHttpClientIdentityModelWebModule)
)]
public class CargonerdsWebPublicModule : AbpModule

Reading the dependencies tells you the shape of the app at a glance:

  • CmsKitProPublicWebModule brings the CMS pages and view components (commenting, contact form).
  • AbpAspNetCoreMvcClientModule + AbpHttpClientWebModule + AbpHttpClientIdentityModelWebModule make it a pure consumer of remote ABP services via dynamic HTTP proxies — there is no local application layer behind these.
  • AbpAspNetCoreMvcUiLeptonXThemeModule provides the public-website chrome.
  • AbpAspNetCoreAuthenticationOpenIdConnectModule wires login to the external AuthServer.

The module overrides the three standard ABP lifecycle hooks: PreConfigureServices (registers localization assembly resources), ConfigureServices (the bulk of the wiring, broken into private ConfigureXxx helpers), and OnApplicationInitialization (the HTTP request pipeline).

Host bootstrap (Program.cs)

Program.Main builds a WebApplication, and is Aspire-aware even though the AppHost never launches it:

var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();      // Cargonerds.ServiceDefaults (OTel, health, resilience)
builder.AddRedisClient("redis");   // Aspire.StackExchange.Redis integration
builder
    .Host.AddAppSettingsSecretsJson()
    .UseAutofac()
    .UseSerilog(/* console + OpenTelemetry sinks */);
await builder.AddApplicationAsync<CargonerdsWebPublicModule>();
var app = builder.Build();
await app.InitializeApplicationAsync();
await app.RunAsync();

ConfigureAuthentication sets up a cookie scheme as the default and OpenID Connect as the challenge scheme, then registers ABP's OIDC handler with AddAbpOpenIdConnect:

context.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = "Cookies";
        options.DefaultChallengeScheme = "oidc";
    })
    .AddCookie("Cookies", options =>
    {
        options.ExpireTimeSpan = TimeSpan.FromDays(365);
        options.CheckTokenExpiration();
    })
    .AddAbpOpenIdConnect("oidc", options =>
    {
        options.Authority = configuration["AuthServer:Authority"];
        options.RequireHttpsMetadata = configuration.GetValue<bool>("AuthServer:RequireHttpsMetadata");
        options.ResponseType = OpenIdConnectResponseType.CodeIdToken;
        options.ClientId = configuration["AuthServer:ClientId"];
        options.ClientSecret = configuration["AuthServer:ClientSecret"];
        options.SaveTokens = true;
        options.GetClaimsFromUserInfoEndpoint = true;
        options.Scope.Add("roles");
        options.Scope.Add("email");
        options.Scope.Add("phone");
        options.Scope.Add("Cargonerds");
    });

Login/logout are handled by Controllers/AccountController.cs, which is a one-liner derived from ABP's base challenge controller:

public class AccountController : ChallengeAccountController { }

Dynamic claims are enabled (AbpClaimsPrincipalFactoryOptions.IsDynamicClaimsEnabled = true) and app.UseDynamicClaims() runs in the pipeline, so permission/claim changes on the server are reflected without forcing a re-login.

Split issuer addresses on Kubernetes (AuthServer:IsOnK8s)

There is a dedicated branch for running behind an in-cluster AuthServer where the browser and the server must reach the identity provider via different URLs. When AuthServer:IsOnK8s is true, the module rewrites issuer/metadata so that:

  • server-to-server metadata is fetched from the internal cluster address (AuthServer:MetaAddress),
  • the browser is redirected to the public host (AuthServer:Authority) for connect/authorize and connect/logout,
  • both addresses are accepted as valid issuers.
if (configuration.GetValue<bool>("AuthServer:IsOnK8s"))
{
    context.Services.Configure<OpenIdConnectOptions>("oidc", options =>
    {
        options.TokenValidationParameters.ValidIssuers = new[]
        {
            configuration["AuthServer:MetaAddress"]!.EnsureEndsWith('/'),
            configuration["AuthServer:Authority"]!.EnsureEndsWith('/'),
        };
        options.MetadataAddress =
            configuration["AuthServer:MetaAddress"]!.EnsureEndsWith('/') + ".well-known/openid-configuration";

        options.Events.OnRedirectToIdentityProvider = async ctx =>
        {
            ctx.ProtocolMessage.IssuerAddress =
                configuration["AuthServer:Authority"]!.EnsureEndsWith('/') + "connect/authorize";
            /* ...chain previous handler... */
        };
        options.Events.OnRedirectToIdentityProviderForSignOut = async ctx =>
        {
            ctx.ProtocolMessage.IssuerAddress =
                configuration["AuthServer:Authority"]!.EnsureEndsWith('/') + "connect/logout";
            /* ...chain previous handler... */
        };
    });
}

Two addresses must both be set in-cluster

The AuthServer:IsOnK8s branch deliberately splits the internal metadata address from the browser-facing issuer. Forgetting either AuthServer:MetaAddress or AuthServer:Authority breaks OIDC login inside the cluster. The Helm chart sets both (see Deployment).

See API Authentication for the wider OpenIddict / token model that the AuthServer exposes.

Theme & layout

ConfigureTheme selects the LeptonX system style and the top-menu MVC layout:

Configure<LeptonXThemeOptions>(o => o.DefaultStyle = LeptonXStyleNames.System);
Configure<LeptonXThemeMvcOptions>(o => o.ApplicationLayout = LeptonXMvcLayouts.TopMenu);

Crucially, Pages/_ViewStart.cshtml selects the public-website LeptonX layout (distinct from the admin application layout) for all pages:

@inject IThemeManager ThemeManager
@{
    Layout = ThemeManager.CurrentTheme.GetPublicLayout();
}

Bundles

ConfigureBundles appends two files to the LeptonX global bundles using ABP's bundling system:

options.StyleBundles.Configure(LeptonXThemeBundles.Styles.Global, b => b.AddFiles("/global-styles.css"));
options.ScriptBundles.Configure(LeptonXThemeBundles.Scripts.Global, b => b.AddFiles("/global-scripts.js"));

wwwroot/global-styles.css only overrides the --lpx-logo CSS variables; global-scripts.js is empty today.

The LeptonX footer is overridden by convention at Themes/LeptonX/Layouts/Application/_Footer.cshtml. It renders the CopyrightLinks and FooterLinks from WhiteLabelingOptions:

@inject IOptionsMonitor<WhiteLabelingOptions> WhiteLabelingOptions
...
@foreach (var l in WhiteLabelingOptions.CurrentValue.CopyrightLinks)
{
    <a href="@l.Url" target="@l.Target">@l.Label</a>
}

Branding

CargonerdsBrandingProvider replaces ABP's DefaultBrandingProvider (service replacement via [Dependency(ReplaceServices = true)]) and pulls the app name and logo from Hub.WhiteLabeling.WhiteLabelingOptions, falling back to the AppName localization key:

[Dependency(ReplaceServices = true)]
public class CargonerdsBrandingProvider(
    IStringLocalizer<CargonerdsResource> localizer,
    IOptions<WhiteLabelingOptions> options
) : DefaultBrandingProvider
{
    public override string AppName =>
        !string.IsNullOrEmpty(options.Value.AppName) ? options.Value.AppName : localizer["AppName"];
    public override string? LogoUrl => options.Value.Logo;
    public override string? LogoReverseUrl => LogoUrl;
}

This is the one piece of genuinely Cargonerds-specific customization, shared in spirit with the other .NET UIs. See Theming & White-Labeling for the full white-labeling model and the WHITE_LABEL_SETTINGS_PATH propagation mechanism.

Two ABP navigation contributors build the chrome (registered in ConfigureNavigationServices):

  • Menus/CargonerdsPublicMenuContributor.cs (IMenuContributor) builds the main menu (Home ~/, Article Sample ~/article-sample, Contact Us ~/contact-us) and the user menu (My Account, Security Logs, Sessions, Logout). The account links point at AuthServer:Authority + Account/... and open with target="_blank"; all are gated with .RequireAuthenticated().
  • Menus/CargonerdsToolbarContributor.cs (IToolbarContributor) adds a LoginLinkViewComponent to the main toolbar only for anonymous users (it checks ICurrentUser.IsAuthenticated).

View-component namespace quirk

LoginLinkViewComponent lives under Cargonerds.Web.Components.Toolbar.LoginLink (file Components/Toolbar/LoginLink/LoginLinkViewComponent.cs), not under the project's Cargonerds.Web.Public.* root namespace. CargonerdsToolbarContributor references it via an explicit using Cargonerds.Web.Components.Toolbar.LoginLink;.

Content & pages

All page models derive from Pages/CargonerdsPublicPageModel.cs (abstract class CargonerdsPublicPageModel : AbpPageModel), which sets LocalizationResourceType = typeof(CargonerdsResource).

Page Route Notes
Index ~/ Landing page: an abp-carousel of picsum.photos placeholders + three Lorem-ipsum featurettes.
ArticleSample ~/article-sample Lorem-ipsum article; renders the CmsKit CommentingViewComponent only when CommentsFeature is enabled.
ContactUs ~/contact-us Placeholder contact page with a Google-Maps iframe (pointing at Antarctica); renders the CmsKit Pro ContactViewComponent only when ContactFeature is enabled.
PrivacyPolicy ~/privacy-policy Template default; empty OnGet.

The CMS components are gated by ABP Global Features at render time, e.g. on the article page:

@if (GlobalFeatureManager.Instance.IsEnabled<CommentsFeature>())
{
    <abp-card-footer>
        @await Component.InvokeAsync(typeof(CommentingViewComponent),
            new { entityType = "SampleArticle", entityId = Guid.Empty.ToString() })
    </abp-card-footer>
}

The comment entity type is registered in the module so CmsKit recognises "SampleArticle":

Configure<CmsKitCommentOptions>(options =>
{
    options.EntityTypes.Add(new CommentEntityTypeDefinition("SampleArticle"));
});

Comment moderation across the solution is gated behind the Comments.UpdateStatus permission — see Permissions Reference.

Infrastructure wiring

ABP's options pattern (Configure<TOptions>(...)) drives the rest of the host. The notable configuration helpers in CargonerdsWebPublicModule:

Concern Setting Detail
Distributed cache AbpDistributedCacheOptions.KeyPrefix "Cargonerds:", optionally suffixed with GetAzureEnvironment()
Data protection SetApplicationName("Cargonerds") Outside Development, persists keys to Redis as Cargonerds-Protection-Keys
Distributed lock IDistributedLockProvider RedisDistributedSynchronizationProvider (Medallion + Redis)
Multi-tenancy AbpMultiTenancyOptions.IsEnabled MultiTenancyConsts.IsEnabled (shared constant)
Background jobs AbpBackgroundJobOptions.IsJobExecutionEnabled false — correct for a stateless presentation host
Virtual file system ReplaceEmbeddedByPhysical In Development, live-edits resources from sibling Domain.Shared / Application.Contracts folders
Localization AddAssemblyResource(typeof(CargonerdsResource), ...) Registers the shared resource for data-annotations localization
reCAPTCHA AddreCAPTCHAV3 Wired with literal "test" keys (not functional as configured)

Both the data-protection and distributed-lock helpers connect directly via ConnectionMultiplexer.Connect(configuration["Redis:Configuration"]), and both early-return when AbpStudioAnalyzeHelper.IsInAnalyzeMode so static analysis runs don't try to open a Redis connection.

Caching and background jobs are described in more depth in their own pages (the public web is one of the hosts that can enqueue jobs but never executes them).

Request pipeline

OnApplicationInitialization wires the middleware order:

if (GlobalFeatureManager.Instance.IsEnabled<UrlShortingFeature>())
    app.UseMiddleware<UrlShortingMiddleware>();
if (env.IsDevelopment()) app.UseDeveloperExceptionPage();
app.UseAbpRequestLocalization();
if (!env.IsDevelopment()) app.UseErrorPage();
app.UseRouting();
app.MapAbpStaticAssets();
app.UseAbpStudioLink();
app.UseAbpSecurityHeaders();
app.UseAuthentication();
if (MultiTenancyConsts.IsEnabled) app.UseMultiTenancy();
app.UseDynamicClaims();
app.UseAuthorization();
app.UseAbpSerilogEnrichers();
app.UseConfiguredEndpoints();

The CmsKit Pro UrlShortingMiddleware is conditionally inserted at the very front of the pipeline when the UrlShortingFeature global feature is enabled.

Configuration

Local development config lives in appsettings.json:

{
  "App": { "SelfUrl": "https://localhost:44332", "DisablePII": false, "HealthCheckUrl": "/health-status" },
  "Redis": { "Configuration": "127.0.0.1" },
  "RemoteServices": {
    "Default": { "BaseUrl": "https://localhost:44354/" },
    "AbpAccountPublic": { "BaseUrl": "https://localhost:44345/" }
  },
  "AuthServer": {
    "Authority": "https://localhost:44345",
    "RequireHttpsMetadata": true,
    "ClientId": "web",
    "ClientSecret": "1q2w3e*"
  }
}

Other config files:

  • appsettings.aspire.json — token-templated values ({web}, {api}, {auth}, {redis}, {messaging}) intended for an Aspire-launched run. See the Gotcha below — this file is not exercised in practice because the AppHost never launches this project.
  • appsettings.secrets.json — commits a real AbpLicenseCode (base64).
  • Properties/launchSettings.json — both the Project and IIS Express profiles listen on https://localhost:44332/.

See the Configuration Reference and appsettings reference for the meaning of the shared App, Redis, RemoteServices and AuthServer keys.

Orchestration status: not in the Aspire graph

This is the key conclusion of analysing the host. Web.Public is not orchestrated by the Aspire AppHost.

graph TD
    subgraph AppHost["Cargonerds.AppHost (Aspire graph)"]
        Mig[DbMigrator]
        Auth[AuthServer]
        Api[HttpApi.Host]
        Admin[Blazor admin]
        FE["JavaScript frontends<br/>(AddAllFrontends)"]
    end
    WP["Cargonerds.Web.Public<br/>(MVC public site)"]
    Helm[["webpublic Helm chart<br/>+ Dockerfile"]]

    AppHost -. "&lt;ProjectReference&gt; only<br/>(build dependency)" .-> WP
    WP -->|deployed via| Helm
    WP -->|OIDC| Auth
    WP -->|remote API| Api

    style WP stroke-dasharray: 5 5

The evidence:

  • src/Cargonerds.AppHost/Cargonerds.AppHost.csproj contains <ProjectReference Include="..\Cargonerds.Web.Public\Cargonerds.Web.Public.csproj" />, which makes the generated Projects.Cargonerds_Web_Public metadata type available — but this only forces a build dependency.
  • src/Cargonerds.AppHost/Program.cs calls AddProjectWithDefaults<...> for exactly four projects: the Migrator, AuthServer, HttpApi.Host and Blazor admin. It never calls AddProject / AddProjectWithDefaults for Cargonerds_Web_Public.
  • The builder.AddAllFrontends() call adds the JavaScript/Next.js apps under the frontends directory — AddAllFrontends returns IResourceBuilder<JavaScriptAppResource>[] (src/Cargonerds.AppHost/Extensions/DistributedAppBuilderExtensions.cs). It does not touch the MVC Web.Public project.
  • A repo-wide search for Cargonerds_Web_Public / Web.Public in *.cs finds matches only inside the Web.Public project itself.

Net effect: the project reference is effectively dangling, the public site does not appear in the Aspire dashboard or manifest, and it is run/deployed independently. This is also called out in the Architecture Overview and Aspire Integration pages.

Running locally

Because it is outside the Aspire run loop, you start Web.Public standalone from your IDE (or dotnet run). It listens on:

https://localhost:44332

It requires its dependencies to be reachable at the URLs in appsettings.json: the AuthServer (https://localhost:44345), HttpApi.Host (https://localhost:44354) and Redis (127.0.0.1). The simplest path is to start the rest of the stack via the AppHost and then launch Web.Public separately. See Debugging for running a single host against deployed/local neighbours, and Running Locally for the Aspire-driven part of the stack.

Deployment: standalone Helm chart

Web.Public ships as the webpublic Helm subchart under etc/helm/cargonerds/charts/webpublic/ (Chart.yaml name webpublic). See the Helm deployment guide for the umbrella chart.

The Deployment (templates/webpublic.yaml) runs the cargonerds/webpublic image, exposes container port 80, and uses a /health-status readiness probe. All configuration is injected as environment variables, including:

Env var Value (templated) Maps to
App__SelfUrl cargonerds.hosts.webpublic helper App:SelfUrl
App__DisablePII global.disablePII App:DisablePII
Redis__Configuration <release>-redis Redis:Configuration
RemoteServices__Default__BaseUrl http://<release>-httpapihost API host (internal)
RemoteServices__AbpAccountPublic__BaseUrl cargonerds.hosts.authserver helper AuthServer (public)
AuthServer__Authority cargonerds.hosts.authserver helper browser-facing issuer
AuthServer__MetaAddress http://<release>-authserver internal metadata address
AuthServer__RequireHttpsMetadata false
AuthServer__IsOnK8s true activates the issuer-rewrite branch
AuthServer__ClientId Cargonerds_Web_Public OpenIddict client id

The Service (webpublic-service.yaml) exposes port 80. The Ingress (webpublic-ingress.yaml) is nginx with cert-manager (cluster-issuer: letsencrypt), force-ssl-redirect, and an enlarged proxy buffer. The host name comes from the cargonerds.hosts.webpublic helper (etc/helm/cargonerds/templates/_helpers.tpl), resolved from deploy-time global.hosts.webpublic.

ClientId differs by environment

Local appsettings.json uses ClientId = web; the Helm deployment uses ClientId = Cargonerds_Web_Public. The AuthServer's OpenIddict client configuration must contain whichever id is in use for the target environment.

Code generation

This project consumes generated DTOs via the CodeGen.config.json AdditionalFile mechanism (the AdditionalFiles item in the .csproj) — see Code Generation.

Gotchas

Committed secrets & placeholder keys

  • appsettings.json and etc/helm/cargonerds/charts/webpublic/values.yaml both commit ClientSecret = "1q2w3e*".
  • appsettings.secrets.json commits a real AbpLicenseCode (base64).
  • reCAPTCHA is wired with literal "test" keys, so v3 captcha is not functional as configured.

PII logging is ON by default

IdentityModelEventSource.ShowPII and LogCompleteSecurityArtifact are enabled unless App:DisablePII = true. The committed appsettings.json sets DisablePII = false, so production must set App__DisablePII (the Helm template wires it from global.disablePII).

Stale Dockerfile target framework

Dockerfile and Dockerfile.local use mcr.microsoft.com/dotnet/aspnet:9.0 and copy from bin/Release/net9.0/publish/, but the .csproj targets net10.0. The publish output path is net10.0, so these Dockerfiles will not find the published bits as written — they look left over from a pre-.NET-10 bump.

Aspire config path is effectively dead for this app

appsettings.aspire.json is consumed only when the AppHost passes USE_ASPIRE_CONFIG=true (set by AddProjectWithDefaults). Since the AppHost never adds this project, that env var is never set for it and the file is not exercised in normal runs. Real config comes from appsettings.json (local) and Helm env vars (k8s).

Health-check key mismatch

The Helm Deployment sets App__HealthUiCheckUrl (http://<release>-webpublic/health-status), while appsettings.json defines App:HealthCheckUrl (/health-status). Worth verifying which key the health-check UI actually reads.

Still the ABP startup template

Empty OnGet page models and placeholder content (the Contact Us map points to Antarctica and the email is info@abp.io) confirm the site is still essentially the ABP startup template, not a finished marketing site.