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:
CmsKitProPublicWebModulebrings the CMS pages and view components (commenting, contact form).AbpAspNetCoreMvcClientModule+AbpHttpClientWebModule+AbpHttpClientIdentityModelWebModulemake it a pure consumer of remote ABP services via dynamic HTTP proxies — there is no local application layer behind these.AbpAspNetCoreMvcUiLeptonXThemeModuleprovides the public-website chrome.AbpAspNetCoreAuthenticationOpenIdConnectModulewires 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();
Authentication (cookie + OIDC)¶
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:
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) forconnect/authorizeandconnect/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:
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.
Footer override (white-labeling)¶
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.
Navigation & toolbar¶
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 atAuthServer:Authority+Account/...and open withtarget="_blank"; all are gated with.RequireAuthenticated().Menus/CargonerdsToolbarContributor.cs(IToolbarContributor) adds aLoginLinkViewComponentto the main toolbar only for anonymous users (it checksICurrentUser.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 realAbpLicenseCode(base64).Properties/launchSettings.json— both theProjectandIIS Expressprofiles listen onhttps://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 -. "<ProjectReference> 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.csprojcontains<ProjectReference Include="..\Cargonerds.Web.Public\Cargonerds.Web.Public.csproj" />, which makes the generatedProjects.Cargonerds_Web_Publicmetadata type available — but this only forces a build dependency.src/Cargonerds.AppHost/Program.cscallsAddProjectWithDefaults<...>for exactly four projects: the Migrator, AuthServer, HttpApi.Host and Blazor admin. It never callsAddProject/AddProjectWithDefaultsforCargonerds_Web_Public.- The
builder.AddAllFrontends()call adds the JavaScript/Next.js apps under the frontends directory —AddAllFrontendsreturnsIResourceBuilder<JavaScriptAppResource>[](src/Cargonerds.AppHost/Extensions/DistributedAppBuilderExtensions.cs). It does not touch the MVCWeb.Publicproject. - A repo-wide search for
Cargonerds_Web_Public/Web.Publicin*.csfinds 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:
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.jsonandetc/helm/cargonerds/charts/webpublic/values.yamlboth commitClientSecret = "1q2w3e*".appsettings.secrets.jsoncommits a realAbpLicenseCode(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.
Related pages¶
- Architecture Overview — where Web.Public sits in the solution and why it is not orchestrated.
- Aspire Integration — the AppHost graph this site is excluded from.
- Solution Structure — the runnable hosts and project references.
- Theming & White-Labeling — branding provider, footer override,
WhiteLabelingOptions. - Blazor Admin and Realtime Frontend — the other user-facing front-ends.
- API Authentication — the OpenIddict / token model behind OIDC login.
- Helm Deployment and Configuration Reference.
- Permissions Reference — the
Comments.UpdateStatusmoderation gate.