ShortLink is a modular URL shortener built with .NET 10 and ABP Framework 10.6.
The reusable module follows the same upper-layer composition pattern as ABP CMS Kit:
shared Common services, separate Public and Admin modules, and unsuffixed aggregate
modules for consumers. SayHello.ShortLink.WebHost combines them with ABP Account,
Identity, permissions, settings, OpenIddict, PostgreSQL, and Redis.
- Authenticated link creation with mandatory email confirmation.
- Seven-character cryptographically random Base62 codes or custom aliases.
- Link editing, activation, expiration, soft deletion, and a 180-day code cooldown.
- Namespace redirects at
/{namespace}/{code}, with backward-compatible root paths. - User-owned namespaces and independent subscription quotas for namespaces and root links.
- Privacy-friendly visit analytics without persisted raw IP addresses or user agents.
- QR codes, per-user quotas, distributed creation rate limits, configurable short-link Origins, and target-domain blocking.
- Redirect-time blocked-domain enforcement with cached parent-domain matching and HTTP 451 pages.
- Admin CSV import for up to 10,000 blocked domains per 1 MB file.
- English and Simplified Chinese module UI.
- Docker Compose deployment with Caddy HTTPS, PostgreSQL, Redis, and a one-shot migrator.
modules/SayHello.ShortLink: reusable ABP application module.Domain.Shared,Domain, andEntityFrameworkCorecontain the single shared model.Common.Application.ContractsandCommon.Applicationcontain reusable upper-layer DTOs and infrastructure used by both application surfaces.Public.*contains ordinary-user link management, analytics, QR codes, and anonymous short-code redirects.Admin.*contains cross-user administration, blocked domains, and global settings.- Unsuffixed
Application*,HttpApi*, andWebprojects are composition-only modules.
modules/SayHello.Subscription: independent product subscription module, using the same Common/Public/Admin and composition-only upper layers.modules/SayHello.ShortLink.Subscription: optional integration module. ItsDomain.Sharedproject owns the subscription definition and itsApplicationproject adapts the Subscription public application contract to ShortLink's domain capability port. Neither business module references the bridge or the other module.host: layered MVC host and unified database migrations.deploy: production Compose, Caddy, certificate, backup, restore, and update assets..github/workflows: CI and container publishing.
The single host exposes:
- Public UI:
/short-links - Namespace management:
/short-links/namespaces - Admin UI:
/admin/short-links - Public API:
/api/short-link/public/* - Admin API:
/api/short-link/admin/* - Anonymous redirects:
/{namespace}/{code}and legacy/explicit root/{code}
New links use the nonempty global setting ShortLink.DefaultNamespace, initially
s: omitting namespace selection creates https://example.com/s/{code}, not a
root-path link. Administrators change this value through the existing short-link
settings page/API. The setting accepts only default/global providers, not tenant,
user, or configuration overrides. Blank, invalid, protected, or user-occupied
values are rejected. Current and historical defaults remain globally reserved;
changing the setting never changes an existing link or its QR code.
Users manage their own namespaces at /short-links/namespaces or
/api/short-link/public/namespaces:
GETlists owned namespaces, optionally filtered byorigin.POSTacceptsoriginandname.DELETE /{id}requires a JSON body containing the readconcurrencyStamp. Stale deletion fails as a concurrency conflict.
Creating a link at /api/short-link/public/links still requires origin.
Set namespaceId to an owned namespace on that Origin, or set useRootPath: true
to request /{code}. These selections are mutually exclusive. Omitting both uses
the current default; update inputs cannot change a link's path identity.
Namespace names contain 1-64 ASCII letters, digits, underscores, or hyphens.
Names and codes are case-sensitive (Foo and foo differ), while system-prefix
protection is case-insensitive. Namespace uniqueness spans all users and tenants
on the normalized Origin; different Origins may reuse a name. Code uniqueness
is (Origin, namespace, code), so root paths and different namespaces may reuse a
code. Namespaces cannot be renamed or moved. Any link reference, including a
soft-deleted link, prevents namespace deletion; even an empty namespace prevents
its domain from being deleted.
Protected first segments include API, administration, subscription, account,
authentication, health, and static-resource routes. Hosts with additional routes
must extend ShortLinkNamespaceRouteOptions.ReservedPrefixes. Protection does not
apply to the second segment: /s/api is a valid short-link path.
Deploy the new schema and namespace-aware application together. The migration
backfills existing links with an empty namespace, preserving every old /{code}
address; it does not prepend s. Do not run an older namespace-unaware build once
namespaced data exists. Downgrade is explicitly blocked when it would lose link
identity, user namespaces, or historical default reservations. Origin-null legacy
fallback is root-only on the configured legacy Origin; namespaced requests never
fall back to another namespace or to root links.
Common, Public, and Admin Application projects do not consume IQueryable or call
GetQueryableAsync. Application services pass explicit filter, sort, paging, tenant, and
owner parameters to Domain repository interfaces. EF Core repositories own all database
filtering, ordering, paging, grouping, and aggregation, and return Domain entities or
Domain read models. Architecture tests enforce this boundary.
The Subscription module provides an administrator-managed subscription catalog and user entitlements. It does not depend on ShortLink or Identity implementation types; the host composes the modules and supplies ABP's standard external user lookup provider. Optional product integrations supply their own product entitlement definitions.
- A plan belongs to one product. Products can have multiple plan tiers.
- A bundle is a catalog combination of plans for different products, not a user subscription. Assigning it creates a separate subscription for each product.
- Each user has at most one effective subscription per product and tenant. Reassigning one product replaces only that product's subscription, even when the previous subscription originated from a bundle.
- Each assignment stores its own entitlement snapshot. Later catalog edits and withdrawal do not alter existing grants. Reassignment captures the latest values.
- Administrators can select, replace, or explicitly clear one published default Free plan for each published product. Configuration is tenant-specific, including the host (no tenant); there is no cross-tenant or host-to-tenant fallback.
- Entitlement queries use an effective explicit subscription first, otherwise the
product's current default. Expired, revoked, or not-yet-effective assignments do
not suppress the default. An unconfigured product still returns
NoSubscription. Defaults never fill missing features in an explicit subscription or override its false, zero, or unlimited values. - Default rights use the plan's live values: edits and replacement affect fallback users on their next query, while actual subscriptions keep their snapshots. Resolution reports the source and plan identity; a default has no subscription ID and does not count as an effective subscription. It creates no subscription, assignment history, or background work.
- Replace or clear a default before withdrawing, archiving, or deleting its plan or product. Stale configuration changes are rejected. Catalog writes are serialized per tenant until transaction completion; default configuration remains per product, and assignment locking remains separate.
- Entitlements are registered in code and configured on plans by administrators.
Values can be Boolean switches, non-negative integer limits with an explicit
unlimited state, one value from a business-provided Enum, or a normalized
Set<string>. Business integrations can provide Enum/Set choices asynchronously. A Set with no choices uses repeatable free-text inputs. An absent entitlement is not unlimited access. - Assignments take effect immediately. Each product can have a different expiration, or remain valid indefinitely. Expiration is evaluated at query time, without a background worker. Revocation and expiration changes apply per product.
- Bundle assignment and all affected replacements are transactional. Stale administrative changes are rejected, and database uniqueness protects current product assignments, including users without a tenant.
- Subscription stores only the external user ID and does not replicate Identity users. Admin search queries the configured ABP external user provider. Preview and assignment require a currently existing, active user in the current tenant; inactive users remain visible in search but cannot receive a new assignment.
Public catalog pages are available at /subscriptions/plans and
/subscriptions/bundles, with default plans marked in the plan catalog.
/subscriptions/mine requires login and shows the current user's applicable default
Free rights separately from actual subscriptions, with independent paging.
Subscription history remains actual assignments only. Administrative pages are under
/admin/subscriptions; product administration shows the selected default or an
explicit unconfigured state.
The API surfaces use /api/subscription/public/* and /api/subscription/admin/*
with separate HTTP client registrations. Catalog publication does not expose user
records or assignment history to anonymous visitors.
The host adds the module model to its existing database and migrations; a standalone consumer can use the module's own DbContext and connection-string configuration. Subscription management permissions are separate from product entitlements.
For another host, compose the appropriate Subscription modules at each layer and provide
ABP's IExternalUserLookupServiceProvider: load AbpIdentityDomainModule for a local
Identity repository, or AbpIdentityHttpApiClientModule and configure the Identity remote
service for distributed deployment. No Subscription-specific Host adapter or local user table
is required. Register a SubscriptionDefinitionProvider through
SubscriptionDefinitionOptions.DefinitionProviders.
Business integrations that own Enum or String-Set features register an
ISubscriptionEntitlementOptionProvider. CanProvide identifies the exact product and
feature owned by that provider; GetOptionsAsync returns its current values. Providers
compose across integrations, while registering more than one provider for the same feature
is rejected explicitly. Returning an empty list for a String-Set feature enables free-text
values; Enum features must return at least one value.
The standalone connection-string name is Subscription (falling back to Default);
table prefix and schema are configurable through SubscriptionDbProperties.
Code inside the Subscription domain can inject ISubscriptionEntitlementChecker.
Cross-module integrations should instead depend on a Subscription Application Contracts
package, allowing the composing Host to supply either a local application service or an
HTTP client proxy. Numeric checks do not reserve or consume quota.
The ShortLink Subscription shared integration package registers product short-link,
Enum feature statistics, String-Set feature domains, and numeric features
max-links, max-custom-codes, max-namespaces, and max-root-links (including
explicit unlimited values).
statistics accepts none, basic, or advanced;
the domain choices come from the current tenant's configured Origin pool. This host's
database seeding creates only missing draft product metadata and preserves
administrative edits. Publish the product and configure/publish its plans in
administration before selecting a default or assigning subscriptions. Free
max-links = 20 and Pro max-links = 100 are administrator-configured examples, not
hard-coded values; seeding does not publish sample plans, select a default, or assign
users.
Additional products and their feature definitions must be registered by the consuming
host or product integration; they are not hard-coded into the Subscription module.
The integration is split by responsibility:
SayHello.ShortLink.Subscription.Domain.Sharedowns and registers the stable product and feature definitions. A Host that owns the Subscription catalog loads this module.SayHello.ShortLink.Subscription.Applicationreplaces ShortLink'sIShortLinkCapabilityProviderand consumes onlyICurrentUserEntitlementAppServicefromSayHello.Subscription.Public.Application.Contracts.
The bridge does not choose how that application contract is implemented. A monolithic Host,
including this one, loads SubscriptionApplicationModule (which includes the local public
application implementation). A distributed ShortLink Host instead loads
SubscriptionPublicHttpApiClientModule and configures the SubscriptionPublic remote
service endpoint. Do not add either implementation module to the bridge itself.
The entitlement contract is intentionally current-user-only. Before calling it, the adapter requires the tenant and user passed by ShortLink's domain port to match ABP's ambient authenticated context. Anonymous or mismatched-subject calls fail authorization; they never fall back to settings or query another user. A future background or administrative workflow for another subject requires a separate, explicitly authorized integration contract.
- With the bridge,
max-linksentirely replacesMaxLinksPerUser. Missing rights deny creation; zero permits no links; unlimited is an explicit grant, not a null or missing-value fallback. Per-hour creation rate limits, email confirmation, permissions, target validation, and domain blocking still apply. - Usage is the current tenant/user's non-deleted links, including disabled and expired links. Committed deletion by the user or an administrator releases capacity; failed operations and rolled-back deletions do not. There is no consumption ledger, monthly reset, or cross-module create/delete event accounting.
- Free 20 and Pro 100 therefore reject the 21st and 101st owned links respectively. A downgrade below current usage blocks further creation, not editing, deletion, QR codes, or existing redirects.
max-namespacesalone controls user namespaces. A positive or unlimited grant permits new user namespaces and new links in owned namespaces, and limits the current tenant/user's namespaces across all Origins. A missing grant or a value<= 0means the benefit is absent: new user namespaces and new links in them are denied. Default-namespace links do not require this feature. Lowering a positive limit below current usage blocks additional namespaces but leaves existing namespaces usable; lowering it to0also blocks new links in them.max-root-linksindependently limits all non-deleted root-path links, including historical links, disabled links, expired links, and Origin-null legacy rows. Every root link also consumesmax-links; custom aliases additionally consumemax-custom-codes. Denied, zero, finite, and unlimited grants remain distinct. Committed link deletion releases link quotas; rollback does not. Existing redirects and link management never require a namespace/root grant.- Creation is serialized per tenant/owner through ABP distributed locking and a
transactional UOW.
ShortLinkManager.CreateAndSaveAsyncchecks and inserts under that lock; an ambient transaction retains the lock until commit or rollback/disposal. Compound workflows acquire namespace-configuration, domain-configuration, owner, then namespace locks; reverse acquisition is explicitly rejected. The olderCreateAsynconly constructs an entity and is not an atomic persistence API. Nontransactional, read-uncommitted, repeatable-read, and snapshot UOWs are rejected by the atomic entry point; use read-committed or serializable transactions. A lock acquisition failure never falls back to an unlocked write. statistics=none(and a missing value) hides ordinary-user statistics and rejects ordering by visit count.basicdiscloses only total visits, including list/get, mutation responses, ordering, and the statistics endpoint.advancedadditionally exposes unique visitors, daily trends, referrers, browsers, and devices.ShortLinkDto.TotalVisitCountremains nullable:nullmeans not disclosed, not zero visits. Administrative responses still return actual counts under their existing permissions.- Each tenant manages its own HTTP/HTTPS Origin pool and must have one enabled default
Origin. The default is available to every otherwise eligible creator. The
domainsSet grants additional enabled Origins; stale or disabled values do not authorize new links. Creation requires an explicit Origin, and a link's Origin is immutable. Codes are unique per Origin/namespace, so the same code can be reused on another path. Disabling an Origin blocks new links but leaves existing redirects working. Any Origin referenced by a namespace or a link, including a soft-deleted link, can only be disabled and cannot be deleted. - Redirects continue collecting visits without consulting subscriptions. Restored statistics access includes history that remains within the original retention policy.
GET /api/short-link/public/links/capabilitiesreturns the authenticated user's current usage, quota state, finite limit/remaining capacity or unlimited flag, statistics level, the default namespace, namespace/root quota usage, and currently selectable Origins. The ShortLink page displays these values; the Subscription UI stays generic. A displayed capability is not a reservation or a substitute for server-side authorization and quota checks.- Each server-side entitlement check uses then-effective rights. Later checks see expired/revoked assignments or changed defaults; already-checked in-flight operations may finish. Subscription administration is not globally locked against link creation.
Before enabling the bridge, publish the product and configure an appropriate default
plan or assign explicit subscriptions. Seeding does not grant quota or statistics
rights automatically. Without a usable quota grant, creation is denied; without a
statistics value, statistics are denied. The tenant's default Origin remains available
without a domains grant, while existing links continue to resolve.
Existing plans and subscription snapshots are not automatically granted the new
namespace/root benefits. Configure those benefits explicitly in the existing typed
subscription administration UI; missing benefits never fall back to standalone grants.
Host role seeding adds namespace management permissions to the default user role,
but permissions alone do not grant subscription benefits.
Removing ShortLinkSubscriptionApplicationModule from a Host restores ShortLink's
setting-backed total quota, normal statistics, enabled namespaces, and unlimited
additional namespace/root quotas without a database migration. Both
modules remain usable on their own. Multi-instance ShortLink deployments must configure
a shared ABP lock provider; this host uses Redis. Process-local locks cannot enforce a
cross-instance quota.
No payments, checkout, automatic renewal, or subscription purchase/upgrade flow is
implemented by this integration. The upgrade migration adds typed entitlement storage
and the tenant Origin catalog, converts legacy statistics values (false to basic,
true to advanced), and attaches legacy links to the seeded default Origin.
The RemoveShortLinkNamespaceEntitlement migration retires the former Boolean
namespaces feature of product short-link; deploy it together with this build,
which no longer defines that feature. It deletes stored namespaces values from
plans and subscription snapshots. A positive or unlimited max-namespaces value is
kept only where namespaces=true was also stored; otherwise it becomes 0, so the
upgrade never grants new namespace rights. Rights that relied on namespaces=true
without a positive quota are removed; grant a positive max-namespaces value
explicitly where they are still wanted. Affected plans and subscriptions receive a
new concurrency stamp, so reload open administration pages and assignment previews.
Downgrading restores namespaces=true wherever a positive or unlimited quota
remains; quotas set to 0 by the upgrade are not restored.
- .NET SDK 10.0.400 or a compatible patch selected by
global.json. - ABP CLI 10.6.x.
- Node.js LTS and Yarn 1.x for MVC client libraries.
- Docker Desktop or Docker Engine.
The checked-in NuGet configuration uses Microsoft's public package proxy because the current development network cannot complete a TLS handshake with NuGet.org directly.
dotnet restore .\SayHello.ShortLink.slnx
abp install-libs --working-directory .\host\src\SayHello.ShortLink.WebHost.Web
dotnet build .\SayHello.ShortLink.slnx --configuration Release --no-restore
dotnet test .\SayHello.ShortLink.slnx --configuration Release --no-buildThe normal test run uses SQLite. PostgreSQL-specific subscription tests are opt-in:
set SUBSCRIPTION_TEST_POSTGRES_CONNECTION_STRING to an isolated PostgreSQL 17
test instance, using an account allowed to create databases. Never use a production
connection string. The tests create uniquely named databases, apply the real host
migrations, and delete only the databases they created.
Coverage includes fresh schemas, upgrading existing AddSubscriptions data with
nullable defaults, Boolean-to-Enum statistics conversion, typed entitlement constraints,
Origin/domain indexes and restrictive references, same-product subscription references,
catalog lock leases, short-link namespace upgrades and guarded downgrades, and retiring
the namespaces entitlement, including its downgrade.
$env:SUBSCRIPTION_TEST_POSTGRES_CONNECTION_STRING = '<isolated PostgreSQL test connection string>'
try {
dotnet test .\host\test\SayHello.ShortLink.WebHost.EntityFrameworkCore.Tests\SayHello.ShortLink.WebHost.EntityFrameworkCore.Tests.csproj --filter FullyQualifiedName~PostgreSql
}
finally {
Remove-Item Env:\SUBSCRIPTION_TEST_POSTGRES_CONNECTION_STRING
}Without the environment variable these tests are explicitly skipped. SQLite tests and model inspection alone do not validate PostgreSQL migration or constraint behavior.
Copy deploy/env.example to deploy/.env, replace every placeholder, generate the
OpenIddict certificate as described in deploy/README.md, then:
docker compose -f .\deploy\compose.yaml up -d postgres redis
docker compose -f .\deploy\compose.yaml --profile migration run --rm dbmigrator
docker compose -f .\deploy\compose.yaml up -d web caddyFor public deployment, point DOMAIN at the VPS before starting Caddy. Never commit .env,
SMTP credentials, database passwords, Redis passwords, visitor-hash keys, or certificates.
ConnectionStrings__Default: PostgreSQL connection string.Redis__Configuration: StackExchange.Redis configuration.ShortLink__Urls__BaseUrl: bootstrap/default Origin used by data seeding and as a diagnostic legacy fallback during migration. New links and QR codes use the immutable Origin stored on each link.ShortLink__Security__OwnHosts__0: public short-link host, blocked as a recursive target.ShortLink__Privacy__VisitorHashKey: at least 32 random UTF-8 bytes.Settings__Abp.Mailing.*: SMTP and sender settings.OpenIddict__ServerCertificate__*: production signing/encryption certificate.
See deploy/README.md for the complete VPS procedure.