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.
- Root-path redirects with 302, 404, and 410 behavior.
- 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 - Admin UI:
/admin/short-links - Public API:
/api/short-link/public/* - Admin API:
/api/short-link/admin/* - Anonymous redirect:
/{code}
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 feature max-links
(including 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.
- 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. 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, so the same code can be reused on another Origin. Disabling an Origin blocks new links but leaves existing redirects working. Any Origin referenced by 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, 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.
Removing ShortLinkSubscriptionApplicationModule from a Host restores ShortLink's
setting-backed quota and normal statistics behavior 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.
- .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,
and catalog lock leases.
$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~SubscriptionPostgreSql
}
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.