Open Closed

A "Handle" based private pages support #10651


User avatar
0
punta created

The application we build (with ABP commercial) supports a feature where users (employees) posses a set of private pages (private site). The "private sites" are accessed with a handle like an @ sign after or before the domain or website address, like www.website.com/@handle or handle@www.website.com. This might be a general ASP.NET Core issue but ABP might have paved a wonderful approach touching on the middleware. I need some important advice on how to do this, best practices and example code.

Thanks in advance!

ABP Version 10.3 MVC UI Tiered

Markdown supported.
Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)

11 Answer(s)
  • User Avatar
    0
    AI-Bot created
    Support Team ABP Agent
    • For www.website.com/@handle (path-based) the cleanest approach in ABP is to treat the handle as a route segment and then (optionally) adjust PathBase so that link generation works correctly across the UI—exactly the same pattern ABP uses for “tenant in the route”.
    • For handle@www.website.com (subdomain / host-based) this is not valid DNS/URL syntax (the @ is not a host label separator). If you meant handle.website.com, that’s host-based routing and should be implemented via subdomains (and optionally ABP’s domain-based tenant resolution). The rest of this answer focuses on the working pattern: /{handle} or /@{handle}.

    1) Path-based handle: /{handle} or /@{handle}

    Step 1 — Add a middleware that detects /@handle and moves it into PathBase

    This preserves ABP’s routing and also makes relative URLs/navigation work (because the app “thinks” it’s hosted under /@handle).

    Put this before app.UseRouting() in your Web/HttpApi.Host OnApplicationInitialization:

    using System.Text.RegularExpressions;
    using Microsoft.AspNetCore.Http;
    using Microsoft.Extensions.DependencyInjection;
    
    public override void OnApplicationInitialization(ApplicationInitializationContext context)
    {
        var app = context.GetApplicationBuilder();
    
        app.Use(async (httpContext, next) =>
        {
            // Matches: /@john or /@john/...  (handle chars: letters, numbers, underscore, dot, dash)
            var match = Regex.Match(httpContext.Request.Path, @"^/@([a-zA-Z0-9_.-]+)(?:/.*)?$");
            if (match.Success)
            {
                var handle = match.Groups[1].Value;
    
                // OPTIONAL: validate handle exists (recommended)
                // var handleStore = httpContext.RequestServices.GetRequiredService<IUserHandleStore>();
                // if (await handleStore.ExistsAsync(handle))
                {
                    if (httpContext.Request.Path.StartsWithSegments(new PathString("/@" + handle),
                            out var matchedPath,
                            out var remainingPath))
                    {
                        var originalPath = httpContext.Request.Path;
                        var originalPathBase = httpContext.Request.PathBase;
    
                        httpContext.Request.Path = remainingPath;
                        httpContext.Request.PathBase = originalPathBase.Add(matchedPath);
    
                        try
                        {
                            await next();
                        }
                        finally
                        {
                            httpContext.Request.Path = originalPath;
                            httpContext.Request.PathBase = originalPathBase;
                        }
                        return;
                    }
                }
            }
    
            await next();
        });
    
        app.UseRouting();
        // ...
    }
    

    What this gives you:

    • Request /@john/posts/42 is internally rewritten to:
      • PathBase = "/@john"
      • Path = "/posts/42"
    • Your controllers/pages can keep “normal” routes (/posts/{id}), while the UI still works under the handle prefix.

    Step 2 — Make the handle available to your app code

    A simple approach is to read it from HttpContext.Request.PathBase:

    public static class HandleHttpContextExtensions
    {
        public static string? GetHandleFromPathBase(this HttpContext httpContext)
        {
            var pb = httpContext.Request.PathBase.Value; // e.g. "/@john"
            if (string.IsNullOrWhiteSpace(pb)) return null;
            return pb.StartsWith("/@") ? pb.Substring(2) : null;
        }
    }
    

    Then in a controller/page/app service (via IHttpContextAccessor) you can resolve the “current private site owner” and load data accordingly.

    Step 3 — Routing endpoints remain standard

    You do not need special ABP routing features for this. Keep your normal endpoints, e.g.:

    [Route("posts")]
    public class PostsController : AbpController
    {
        [HttpGet("{id}")]
        public IActionResult Get(int id) => Ok();
    }
    

    2) ABP-specific “best practice” angle

    • The middleware + PathBase technique is proven in ABP for multi-tenancy “tenant in route” scenarios (it avoids broken navigation links and incorrect base paths).
    • If your “private site” is conceptually similar to multi-tenancy (each handle is an isolated site), you can even model it like a tenant resolution strategy (route/domain contributors). Otherwise, keep it as a custom “handle resolution” abstraction.

    3) Sources

    • https://abp.io/community/articles/ah7oru97 (shows the same PathBase middleware pattern used for route-based resolution in ABP)
    • https://docs.abp.io/en/abp/latest/Multi-Tenancy (ABP’s official multi-tenancy resolution concepts; useful if you decide to model handles similarly)

    This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    punta created

    Wonderful! Let me wait for a Support Team

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    1
    maliming created
    Support Team Fullstack Developer

    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.
    • To resolve the handle from anywhere in your app code, inject 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

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    punta created

    Thanks Maliming very much!! You are helping me a lot. You are clear and to the point, as always. The source links you associate are very helpful too.

    A few more points to clarify:

    1. When Punta, our employee, provides his website address to external systems (like social apps), he just writes as www.website.com/@Punta and our routing system directs the navigator to our PrivateSiteController directly.
    2. There is an a tag writing area just on our website, with a caption like whom do you want to get? put the a tag here and go. Visitors will put the a tag of the person there and press a button or strike enter, and the routing system gets the visitor to PrivateSiteController directly.
    3. Ours is the standard model-view-controller style.

    Don't you think need for middleware or PathBase rewriting; what do you say about the AI's idea above?

    Thanks also in advance!

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    1
    maliming created
    Support Team Fullstack Developer

    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).

    Option 1 — Single action, two route templates (simplest)

    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.

    Option 2 — Controller-level route prefix + multiple actions

    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.

    Option 3 — Conventional route in Program.cs

    If 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.

    Optional — restrict invalid handles at the routing layer

    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.

    Scenario 2 — the "enter a handle and go" form

    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>
    

    About the AI's PathBase middleware idea

    It'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:

    • It rewrites 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).
    • Cross-handle navigation (your Scenario 2 — jumping from /@Punta/... to /@John) is harder, because link generation under a PathBase of /@Punta will keep prepending /@Punta/ unless you opt out.
    • It's an extra moving part to maintain, with no benefit over attribute routing for what you described.

    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

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    punta created

    Very detailed, very useful LECTURE! ... invalid handles, awesome! Thanks a lot Maliming!!!


    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Great : )

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    punta created

    Hi, Maliming! Success story: I used the Option 2 above: Controller-level route prefix + multiple actions and it worked SUCCESSFULLY.

    But One Caveat Once navigated to, all the navigation links on the private site lost the Handle and didn't work, until I worked out around it and added the Handle as a route attribute, as with the following links:

    <ul class="navbar-nav flex-grow-1"> <li class="nav-item"> <a class="nav-link text-dark" asp-route-handle="@Model.Handle" asp-action="Index">Home</a> </li> <li class="nav-item"> <a class="nav-link text-dark" asp-route-handle="@Model.Handle" asp-action="About">About</a> </li> </ul>

    Triumph! Thanks again!

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    Hi,

    Great that Option 2 worked! Your asp-route-handle="@Model.Handle" fix is exactly the right pattern. Here's the why behind it, so the same gotcha doesn't surprise you elsewhere.

    Why navigation links lost the handle

    When you click from /@Punta/About to Home via <a asp-action="Index">, ASP.NET Core walks the URL generation algorithm:

    • Ambient values (current request): { controller = PrivateSite, action = About, handle = Punta }
    • Explicit values (from the tag helper): { controller = PrivateSite, action = Index }

    The required values for controllers are controller + action, processed left-to-right. Rule 2.4 in the URL generation reference → Route value invalidation algorithm says:

    If the ambient value and the explicit value are present, and the two values are different, reject the ambient value and all subsequent ambient values.

    So when action = Index (explicit) ≠ action = About (ambient), the algorithm rejects the ambient action and every ambient value after it — including handle. The template @{handle} then has no value, URL generation fails, and the anchor tag helper emits an empty href. Same-action self-links work because there's no action mismatch, so the ambient handle survives.

    This is by design — the algorithm intentionally avoids mixing route values that belong to a different endpoint. Passing handle explicitly via asp-route-handle is the standard way to opt back in.

    A small DRY tweak

    You can drop the @Model.Handle dependency (so the layout doesn't require every page model to expose a Handle property) by reading the route value directly:

    @{
        var handle = (string?)ViewContext.RouteData.Values["handle"];
    }
    <a class="nav-link" asp-route-handle="@handle" asp-action="Index">Home</a>
    <a class="nav-link" asp-route-handle="@handle" asp-action="About">About</a>
    

    The handle variable is scoped to the layout file, and views no longer need to plumb it through their models.

    Same gotcha in controller code

    Heads-up: if anywhere in your controllers you call Url.Action("About") or RedirectToAction("About") to jump between actions of PrivateSiteController, you'll hit the same invalidation and get an empty URL / a redirect to nowhere. Pass the handle explicitly there too:

    return RedirectToAction("About", new { handle = RouteData.Values["handle"] });
    

    Thanks

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    punta created

    Amazing! A good Nurture to my knowledge underneath. All this is useful as my PrivateSite navigation links are prepared dynamically at runtime, and page components may also have jumping links too.

    Thanks very much for nurturing everyone reading it!

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
  • User Avatar
    0
    maliming created
    Support Team Fullstack Developer

    : )

    Markdown supported.
    Copy, paste, or drag & drop images and files (max 100 MB per file, 100 MB total per post)
Boost Your Development
ABP Live Training
Packages
See Trainings
Mastering ABP Framework Book
The Official Guide
Mastering
ABP Framework
Learn More
Mastering ABP Framework Book
Made with ❤️ on ABP v10.8.0-preview. Updated on September 28, 2026, 11:44
1
ABP Assistant
🔐 You need to be logged in to use the chatbot. Please log in first.