Hi,
For all three scenarios you described, plain ASP.NET Core attribute routing is enough — no middleware, no PathBase rewriting. Below are three working ways to do it (all standard MVC, pick the one that matches your style).
public class PrivateSiteController : AbpController
{
[HttpGet("@{handle}")]
[HttpGet("@{handle}/{**subpath}")]
public async Task<IActionResult> Index(string handle, string? subpath)
{
var owner = await _privateSiteAppService.FindByHandleAsync(handle);
if (owner == null)
{
return NotFound();
}
return View(new PrivateSiteViewModel(owner, subpath));
}
}
/@Punta hits the first template, /@Punta/posts/42 hits the second (with subpath = "posts/42"). Use {**subpath} (two asterisks) so / characters inside the subpath are preserved instead of URL-encoded — see Route templates → catch-all.
Putting two [HttpGet] attributes on the same action is officially supported, see Multiple attribute routes.
If a private site has fixed sub-pages (e.g. /@Punta, /@Punta/posts/42, /@Punta/about):
[Route("@{handle}")]
public class PrivateSiteController : AbpController
{
[HttpGet] // GET /@Punta
public IActionResult Index(string handle) { /* ... */ return View(); }
[HttpGet("posts/{id:int}")] // GET /@Punta/posts/42
public IActionResult Post(string handle, int id) { /* ... */ return View(); }
[HttpGet("about")] // GET /@Punta/about
public IActionResult About(string handle) { /* ... */ return View(); }
}
Routes on the controller are prepended to routes on each action — see Combining attribute routes. This style plays nicest with link generation: while serving /@Punta/about, calling Url.Action("Post", new { id = 42 }) (or <a asp-action="Post" asp-route-id="42">) automatically produces /@Punta/posts/42, because handle = "Punta" is an ambient route value and the target action's template also has {handle}. You don't have to pass handle manually inside the controller.
Program.csIf you prefer all routes in one place:
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "private-site",
pattern: "@{handle}/{**subpath}",
defaults: new { controller = "PrivateSite", action = "Index" });
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
Then PrivateSiteController is a plain controller with no [Route] attributes — Index(string handle, string? subpath) works as-is. Reference: MapControllerRoute.
If you want bad handles to never reach the action (and instead get a clean 404), add inline constraints:
[HttpGet("@{handle:length(2,32):regex(^[[A-Za-z0-9_.-]]+$)}")]
[HttpGet("@{handle:length(2,32):regex(^[[A-Za-z0-9_.-]]+$)}/{**subpath}")]
length(2,32) enforces 2–32 characters; the regex restricts to letters/digits/_/./-. [[ and ]] are how you escape literal [ ] inside a route template (see Route constraints). Multiple constraints chain with :.
Then existence is still validated inside the action against your store.
Server-side redirect (clean and lets you put validation in one place):
public class HandleLookupController : AbpController
{
[HttpPost("/handle/go")]
[ValidateAntiForgeryToken]
public IActionResult Go(string handle)
{
if (string.IsNullOrWhiteSpace(handle))
{
return RedirectToAction("Index", "Home");
}
return Redirect("/@" + Uri.EscapeDataString(handle.Trim()));
}
}
<form method="post" action="/handle/go">
@Html.AntiForgeryToken()
<input name="handle" placeholder="Enter a handle" />
<button type="submit">Go</button>
</form>
PathBase middleware ideaIt's a real pattern, but it targets a different problem: when each handle should behave like an isolated sub-site where every page, every static asset, every generated URL is rooted under /@handle/... (think GitHub Pages-style username.github.io/...). For that style you do want PathBase = "/@handle" so the framework treats the whole app as if it were hosted there.
For your case — one controller (or a small group of actions) loading the correct user's data based on the handle — that machinery is overkill and brings real downsides:
Request.Path and Request.PathBase for every matching request, which can interact awkwardly with ABP's built-in middlewares (auth, antiforgery, exception handling) and with non-handle routes you also serve from the same host (/Account/Login, /Identity/..., Swagger, health checks, static files)./@Punta/... to /@John) is harder, because link generation under a PathBase of /@Punta will keep prepending /@Punta/ unless you opt out.So my recommendation is: stay with plain attribute routing (Option 1 or 2). If later you grow into a true sub-site model (per-handle theming, per-handle static asset folders, etc.), you can introduce the PathBase middleware then — it's an additive change, not something you need to commit to upfront.
References:
Thanks
Hi,
Good news — for www.website.com/@handle you don't need any middleware or PathBase rewriting. This is plain ASP.NET Core routing. The @ character is not a reserved routing character (only { } [ ] are), so you can use it as a literal directly in a route template. ABP doesn't need to add anything on top of this.
Pick whichever style matches your UI:
Razor Pages (Pages/PrivateSite/Index.cshtml):
@page "/@{handle}/{**subpath?}"
@model PrivateSiteModel
MVC controller:
public class PrivateSiteController : AbpController
{
[HttpGet("@{handle}")]
[HttpGet("@{handle}/{**subpath}")]
public IActionResult Index(string handle, string? subpath)
{
// load the private site for `handle`
return View();
}
}
Minimal API:
app.MapGet("/@{handle}/{**subpath}", (string handle, string? subpath) => ...);
A few things worth knowing:
{**subpath} (two asterisks) is a catch-all that preserves the / separators. {*subpath} (one asterisk) URL-encodes them to %2F, which you usually don't want for a private-site sub-tree. See Route templates → catch-all.@ is just literal text in a route template, so the route /@{handle}/... only matches paths that actually start with /@. Standard routes like /Account/Login, /Identity/..., ABP swagger, etc. are not affected — ASP.NET Core's route precedence considers literal segments more specific than parameter segments. See Route template precedence.handle is fine as a parameter name. Reserved names are only action, area, controller, handler, page — see Reserved routing names.IHttpContextAccessor and read RouteData.Values["handle"], or define a small ICurrentHandle abstraction backed by it — same pattern as ICurrentTenant.About the second form, handle@www.website.com — that one isn't really usable as a routing scheme. In URI syntax (RFC 3986), userinfo@host is the userinfo component, and browsers treat http://handle@www.website.com/ as HTTP Basic auth credentials (the handle part gets sent in the Authorization: Basic ... header, not as part of the path or host). It never reaches your routing layer as something you can dispatch on.
If what you actually want is a subdomain like handle.website.com, that's host-based routing — you'd map the wildcard subdomain in DNS and either filter by Host in middleware/endpoint metadata, or model it the same way ABP's domain-based tenant resolver works (see ABP DomainTenantResolveContributor). Let me know if that's the direction you want and I can share an example.
References:
Thanks
Hi,
A few things worth knowing before you start, because the design choices here matter more than the code.
ABP has two different things and the names are easy to confuse:
IBackgroundJobManager.EnqueueAsync(...) — fire-and-forget, one-time work item. Good for "user just uploaded an image, process this single one in the background."BackgroundWorker — recurring task that runs on a schedule. Good for "scan nightly and process whatever still needs retina versions."Your scenario is the second one. You don't need Quartz, Hangfire, or any extra package for this — the default ABP background worker is enough. Quartz integration only becomes useful if you later need cron expressions, persistent schedules, or cluster-wide single-trigger guarantees across multiple worker hosts. For one host running a periodic scan, skip it.
The two can also be combined: the worker scans for "images needing retina versions" and enqueues a BackgroundJob per image, so multiple jobs can run in parallel and retries are handled per-image. But for a first version, doing the work inline in the worker is fine.
The naive instinct is "create one trigger per timezone." Don't. Instead:
ABP already gives you everything you need for this — no need to build your own timezone table:
TimingSettingNames.TimeZone (key: Abp.Timing.Timezone). Tenant admins can set it from the standard settings UI per tenant.IClock.ConvertToUserTime(DateTime utcNow) converts UTC to the tenant's local time, using ICurrentTimezoneProvider.TimeZone.ICurrentTimezoneProvider.Change(tz) gives you a disposable scope to swap the active timezone inside the worker loop.One requirement: configure the clock to use UTC, otherwise SupportsMultipleTimezone returns false and ConvertToUserTime silently returns the input unchanged:
Configure<AbpClockOptions>(options =>
{
options.Kind = DateTimeKind.Utc;
});
Important — read before flipping this switch. AbpClockOptions.Kind is an application-wide setting, not a worker-local one. The default is Unspecified, which effectively disables the entire ABP Clock/timezone system. Switching to Utc changes how Clock.Now, Clock.Normalize, ConvertToUserTime, and ConvertToUtc behave everywhere in the app — any code that stores, compares, or returns DateTime values is affected. If your app already has data and code written under Unspecified (or Local), audit it before flipping this. Full background, including all the conversion semantics and edge cases:
https://abp.io/docs/latest/framework/infrastructure/timing
public class RetinaImageNightlyWorker : AsyncPeriodicBackgroundWorkerBase
{
public RetinaImageNightlyWorker(
AbpAsyncTimer timer,
IServiceScopeFactory serviceScopeFactory)
: base(timer, serviceScopeFactory)
{
Timer.Period = 30 * 60 * 1000; // 30 minutes
}
protected override async Task DoWorkAsync(PeriodicBackgroundWorkerContext workerContext)
{
var tenantStore = workerContext.ServiceProvider.GetRequiredService<ITenantStore>();
var currentTenant = workerContext.ServiceProvider.GetRequiredService<ICurrentTenant>();
var timezoneProvider = workerContext.ServiceProvider.GetRequiredService<ICurrentTimezoneProvider>();
var settingProvider = workerContext.ServiceProvider.GetRequiredService<ISettingProvider>();
var clock = workerContext.ServiceProvider.GetRequiredService<IClock>();
var processor = workerContext.ServiceProvider.GetRequiredService<IRetinaImageProcessor>();
foreach (var tenant in await tenantStore.GetListAsync(includeDetails: false))
{
using (currentTenant.Change(tenant.Id))
{
var tenantTz = await settingProvider.GetOrNullAsync(TimingSettingNames.TimeZone);
if (tenantTz.IsNullOrWhiteSpace()) continue;
using (timezoneProvider.Change(tenantTz))
{
var localNow = clock.ConvertToUserTime(DateTime.UtcNow);
if (localNow.Hour < 1 || localNow.Hour >= 4) continue; // outside the 01:00-04:00 window
await processor.ProcessPendingImagesAsync(workerContext.CancellationToken);
}
}
}
}
}
A few things to notice:
ICurrentTenant.Change switches the data filter so your repositories see only that tenant's data.ICurrentTimezoneProvider.Change is flow-scoped (AsyncLocal-based), so it's safe inside the loop.ProcessPendingImagesAsync (e.g. 50 images per pass) so a single pass doesn't run for hours and block the next tick.Don't reprocess what you've already done. Add a small piece of state on the image record — a RetinaProcessedAt timestamp, a Status enum, or a hash of the source — and let the worker query for "rows where retina is missing or stale." This also handles failures naturally: a failed image stays "pending" and gets retried on the next pass. Cap the retry count so a permanently broken image doesn't loop forever.
Keep the original and the retina version in whatever storage you already use. ABP's BlobStoring module is convenient if you don't have one yet (it's tenant-aware out of the box), but anything works — your local DB, S3, Azure Blob, file system. Just store enough metadata to locate both versions.
This is where ABP doesn't really opine. "Retina = 2× width and height of the source" is a one-liner in any image library: pick whatever you're comfortable with (a managed library, a native binding, or even shelling out to an external tool). Just keep the resize code behind a thin domain/application service interface (IRetinaImageProcessor above) so the worker stays focused on orchestration and you can swap the underlying library without touching the scheduling logic. One thing to keep in mind: image resize is CPU and memory heavy, so if traffic grows it's worth running the worker in a dedicated host rather than sharing the web app's process.
AbpClockOptions.Kind = DateTimeKind.Utc (required for the timezone helpers to work — but this is application-wide, see the warning in section 2 and the timing docs before changing it on an existing app).Abp.Timing.Timezone setting filled in — otherwise the worker will skip them.Thanks
Hi,
Yes, you can. ITemplateRenderer.RenderAsync is just a string-template renderer — it doesn't care whether the output is HTML, plain text, or a single line for an email subject. So you simply register a second template per event for the subject and render it the same way you render the body.
For the subject template, don't pass a layout — you don't want the HTML layout wrapped around a one-line subject.
public class AppEmailTemplateDefinitionProvider : TemplateDefinitionProvider
{
public override void Define(ITemplateDefinitionContext context)
{
context.Add(new TemplateDefinition(
$"{AppNotificationEvents.UserRegistered}.Subject",
localizationResource: typeof(AppResource))
.WithVirtualFilePath("/Emailing/Templates/UserRegistered.Subject.tpl", isInlineLocalized: true));
context.Add(new TemplateDefinition(
$"{AppNotificationEvents.UserRegistered}.Body",
layout: StandardEmailTemplates.Layout,
localizationResource: typeof(AppResource))
.WithVirtualFilePath("/Emailing/Templates/UserRegistered.Body.tpl", isInlineLocalized: true));
}
}
UserRegistered.Subject.tpl is just one line:
Welcome {{model.user_name}} to {{model.tenant_name}}!
public virtual async Task DispatchEmailAsync(
string eventCode, string to, object model, string? cultureName = null)
{
var enabled = await SettingProvider.GetAsync<bool>($"{eventCode}.EmailEnabled");
if (!enabled)
{
return;
}
var subject = await TemplateRenderer.RenderAsync($"{eventCode}.Subject", model, cultureName);
var body = await TemplateRenderer.RenderAsync($"{eventCode}.Body", model, cultureName);
await EmailSender.QueueAsync(to, subject.Trim(), body);
}
subject.Trim() removes the trailing newline that template engines add to the rendered output — without it, some SMTP servers reject or fold the subject line.
The Text Template Management module lists every registered TemplateDefinition in its UI (it just calls ITemplateDefinitionManager.GetAllAsync() under the hood). So once you register the .Subject template, the host admin sees it next to the .Body template and can edit both independently — no extra configuration, permission, or feature flag needed.
Thanks
Hi,
Short answer: Suite (v10.3) can't really help here. Its entity designer doesn't have a 1:1 option at all — it generates 1:N navigation properties (HasOne(...).WithMany().HasForeignKey(...)) and N:N connection tables. There's no template that emits WithOne(), so neither the PK-to-PK shape nor a "separate FK column with WithOne()" 1:1 can be generated by Suite directly. It's also not designed to "scaffold UI on top of an existing hand-written entity" — Suite generates the entity, EF config, application service, controller, and pages together from its own model.
So your two realistic options are:
Hand-write Person with the PK-to-PK shape from my previous answer. This keeps the relationship a true 1:1, the handler stays clean (new Person(eventData.Id)), and you write any application service / UI yourself if you ever need them. In practice Person is a satellite of IdentityUser populated by the event handler and rarely needs its own admin CRUD page (user management lives in the Identity Pro UI), so this is what I'd recommend.
Drop the strict 1:1 and use a separate UserId FK column with a unique index. This is what Suite can actually scaffold (Domain + Application + Controller + UI), but model-wise it's a 1:N relationship that you "force" into 1:1 with a unique index — not a true EF Core 1:1. You also pay an extra column and have to use _guidGenerator.Create() for Person.Id. Pick this only if you really want Suite to generate the full CRUD page for Person.
Thanks
👍
Hi,
Quick correction first — IdentityUserEto does not exist. The earlier AI answer was wrong. What you actually have in ABP Commercial is Volo.Abp.Identity.IdentityUserCreatedEto (in Volo.Abp.Identity.Pro.Application.Contracts). For the delete side, ABP Commercial does not ship a matching IdentityUserDeletedEto, so you use the framework-level EntityDeletedEto<UserEto> from Volo.Abp.Users.
Also you don't need to register EtoMappings.Add<IdentityUser, ...>() — AbpIdentityDomainModule already does that and turns on auto-event publishing for you.
Person.Id should be the user's IdFor a real 1-to-1 with IdentityUser in EF Core, the cleanest approach is a PK-to-PK relationship (shared primary key): Person.Id is both the primary key and the foreign key to IdentityUser.Id. No separate UserId column, no extra unique index needed, and the relationship is automatically required.
public class Person : Entity<Guid>
{
public string FullName { get; set; }
protected Person() { }
public Person(Guid identityUserId) : base(identityUserId) { }
}
This works directly with the standard ABP application template, because your MyProjectNameDbContext is already declared as [ReplaceDbContext(typeof(IIdentityDbContext))], implements IIdentityDbContext, and calls builder.ConfigureIdentity() — so IdentityUser is part of the same model and the fluent API below resolves the FK target without any extra setup.
builder.Entity<Person>(b =>
{
b.ToTable("Persons");
b.ConfigureByConvention();
b.HasOne<IdentityUser>()
.WithOne()
.HasForeignKey<Person>(p => p.Id);
});
This means inside the event handlers below, you pass eventData.Id (the IdentityUser.Id) directly as the Person's primary key — no IGuidGenerator needed.
Person when an IdentityUser is createdusing System;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.EventBus.Distributed;
using Volo.Abp.Identity;
public class PersonCreator :
IDistributedEventHandler<IdentityUserCreatedEto>,
ITransientDependency
{
private readonly IRepository<Person, Guid> _personRepository;
public PersonCreator(IRepository<Person, Guid> personRepository)
{
_personRepository = personRepository;
}
public async Task HandleEventAsync(IdentityUserCreatedEto eventData)
{
if (await _personRepository.AnyAsync(x => x.Id == eventData.Id))
{
return; // idempotent — important for distributed handlers
}
await _personRepository.InsertAsync(new Person(eventData.Id), autoSave: true);
}
}
IdentityUserCreatedEto carries Id, TenantId and a Properties dictionary (SendConfirmationEmail, AppName). If you need UserName / Email / Name on the Person, fetch the user inside the handler via IIdentityUserRepository or IdentityUserManager.
Person when the IdentityUser is deletedA note before the code: IdentityUser is soft-deleted by ABP (it inherits FullAuditedAggregateRoot<Guid> which implements ISoftDelete), so deleting a user only sets IsDeleted = true — the row stays in the database. That means a database-level ON DELETE CASCADE on the FK does not fire and you cannot rely on EF Core's cascade behavior to remove Person. You really do need an event handler:
using System;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Domain.Entities.Events.Distributed;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.EventBus.Distributed;
using Volo.Abp.Users;
public class PersonRemover :
IDistributedEventHandler<EntityDeletedEto<UserEto>>,
ITransientDependency
{
private readonly IRepository<Person, Guid> _personRepository;
public PersonRemover(IRepository<Person, Guid> personRepository)
{
_personRepository = personRepository;
}
public async Task HandleEventAsync(EntityDeletedEto<UserEto> eventData)
{
var person = await _personRepository.FindAsync(eventData.Entity.Id);
if (person != null)
{
await _personRepository.DeleteAsync(person, autoSave: true);
}
}
}
EntityDeletedEto<UserEto> is auto-published by ABP for any IdentityUser deletion path, so no extra wiring is needed on the publisher side. If your Person also implements ISoftDelete, DeleteAsync will respect that and only flag it as deleted; otherwise it removes the row.
IdentityUserCreatedEtoIt is only published from IIdentityUserAppService.CreateAsync (the Identity Pro management UI / API). It will NOT fire when a user is created via:
IdentityUserManager.CreateAsync call (e.g. custom registration logic)If you also need Person in those cases, swap the create handler to IDistributedEventHandler<EntityCreatedEto<UserEto>> — that one is auto-published for every IdentityUser insert path:
public class PersonCreator :
IDistributedEventHandler<EntityCreatedEto<UserEto>>,
ITransientDependency
{
// ... same body, use eventData.Entity.Id
}
Put the handlers in your .Application project (it already references Volo.Abp.Identity.Pro.Application.Contracts for IdentityUserCreatedEto).
Thanks