Site lookup plug for multi-site applications with host-based site resolution.
This plug identifies the current site based on the incoming HTTP Host header and stores site information in both connection assigns and session for use throughout the request lifecycle in controllers and LiveViews.
Features
- Host-based site resolution with port normalization
- Automatic site state caching and retrieval via SiteState
- Master sessions: a master's own token leaves the master site's requests unscoped
- Tenant isolation using Ash framework integration
- Graceful error handling with custom 503 pages
- Session persistence for site information
Workflow
- Host Extraction: Extracts and normalizes host from request headers
- Site Lookup: Queries SiteState cache for host-to-site mapping
- Site Validation: Checks site active status
- Redirects: An address one of the site's redirect rules names is answered with that redirect
and halted, before the session is touched — see
MishkaCmsCore.Runtime.Redirects - Sign-in Pool: On the master site,
?master_user=picks the accounts its sign-in page uses - Tenant Assignment: Sets Ash tenant context from the session's token
- State Storage: Persists site data in assigns and session
Usage
Add it to the browser pipeline after the session is fetched and before :load_from_session, with
confirm_tenant/2 straight after :load_from_session. The order is the rule: :load_from_session
verifies the session's token against the tenant this plug chose, and confirm_tenant/2 checks the
user that verification loaded.
import AshAuthentication.Plug.Helpers
import MishkaCmsCore.Runtime.Plugs.SiteLookupPlug, only: [confirm_tenant: 2]
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :protect_from_forgery
plug MishkaCmsCore.Runtime.Plugs.SiteLookupPlug
plug :load_from_session
plug :confirm_tenant
end
scope "/", MyAppWeb do
pipe_through :browser
# Your routes here
endMasters
A master is a user with no site. Whether a request belongs to one is read from the signed-in
user's own token, never from the address: a master's token is issued with no site, and
:load_from_session recognises it only on a request this plug left unscoped. The master site —
the one whose record has master: true — leaves a request unscoped for such a token; every other
site scopes every request to itself, so a master signs in on the master site's address and
nowhere else.
Before anyone is signed in, the master site's sign-in page is told which accounts to use:
# The master accounts
"/auth?master_user=true"
# The site's own accounts
"/auth?master_user=false"The choice is kept in the session as :master_mode and unscopes only the sign-in pages of a
visitor with no token — the addresses sign_in_path?/2 names. It grants no access, a signed-in
user's token outranks it, and every other site ignores it and forgets any it finds, telling a
plugin on web:request:master_sign_in_refused with %{site: site}.
confirm_tenant/2 runs after :load_from_session and holds the rest of the rule: an unscoped
request is a master's, or a visitor's on that sign-in page.
Site Resolution Examples
# Request: example.com -> Looks up site by "example.com"
# Request: example.com:4000 -> Normalizes to "example.com"
# Request: subdomain.example.com -> Looks up site by "subdomain.example.com"Error Handling
- Site Not Found: Returns 503 with "Service Unavailable" page
- Inactive Site: Returns 503 with "Site Temporarily Unavailable" page
- Missing Host: Treated as site not found scenario
Integration with Other Components
- SiteState: Leverages runtime state management for fast site lookups
- Ash Framework: Automatic tenant context setting for multi-tenant data isolation
- Session Management: Persists site information across requests
- LiveView: Site information available in LiveView assigns
Production Considerations
- Host header validation should be handled at load balancer level
- Site state caching reduces database queries for each request
- The master site's address should be known only to its masters: no other site links to it
- Error pages are cached and served efficiently
Summary
Functions
The address of the site's sign-in page in language, or of the page under it — /:lang/auth
as MishkaCmsCore.Runtime.Languages.url/3 builds it, so a visitor sent to sign in, or to sign
out, lands on the page in the language they were reading. A language the site does not publish
in, and nil, are the site's first.
Performs site lookup based on Host header and manages site context.
Confirms the tenant call/2 chose, once :load_from_session has read the session's user.
Initializes the plug with configuration options.
Whether segments — a request path split into its segments, as conn.path_info holds it — is an
address a sign-in happens on for site: /auth, /api/auth and every address under them, and
/<language>/auth and every address under it when <language> is one of the site's languages
(MishkaCmsCore.Runtime.Languages.site/1). Whole segments only, so /authors is a page like any
other. nil for site recognises only the addresses without a language.
The site a request's host names — the Host header without its port, as
MishkaCmsCore.Runtime.OriginValidator.host_name/1 reads it — as
MishkaCmsCore.Runtime.States.SiteState.get_by_host/1 answers.
Functions
The address of the site's sign-in page in language, or of the page under it — /:lang/auth
as MishkaCmsCore.Runtime.Languages.url/3 builds it, so a visitor sent to sign in, or to sign
out, lands on the page in the language they were reading. A language the site does not publish
in, and nil, are the site's first.
Every place that sends a visitor to sign in asks this, with the language they read pages in: the
socket's for a runtime page, the session's (MishkaCmsCore.Runtime.Languages.session/0) anywhere
else.
# site.languages ["en", "fa"], site.language_urls "prefix_others"
SiteLookupPlug.auth_address("fa", site.id) #=> "/fa/auth"
SiteLookupPlug.auth_address(nil, site.id) #=> "/auth"
SiteLookupPlug.auth_address("en", site.id, "/sign-out") #=> "/auth/sign-out"
Performs site lookup based on Host header and manages site context.
This is the main plug function that orchestrates the entire site resolution workflow:
- Extracts and normalizes the host from request headers
- Queries SiteState for site information by host
- Validates site status and manages tenant context
- Stores site information in connection assigns and session
Parameters
conn- The Plug.Conn struct representing the HTTP connectionopts- Plug options (from init/1, currently unused)
Returns
Modified Plug.Conn with:
:current_siteassign containing site data (if found and active):current_sitesession data for persistence- Ash tenant context set for multi-tenant data isolation
- HTTP 503 response for inactive or missing sites
Examples
# Successful site lookup
conn = SiteLookupPlug.call(conn, %{})
site = conn.assigns[:current_site]
# => %MishkaCms.Content.Site{host: "example.com", active: true, ...}
# Access in controller
def index(conn, _params) do
site = conn.assigns[:current_site]
render(conn, "index.html", site: site)
end
# Access in LiveView
def mount(_params, session, socket) do
site = session["current_site"]
{:ok, assign(socket, :site, site)}
endResponse Scenarios
- Active Site Found: Connection continues with site context
- Redirected Address: The rule's status and
location, halted, with:current_siteassigned - Inactive Site: 503 "Site Temporarily Unavailable" response
- Site Not Found: 503 "Service Unavailable" response
- Master session on the master site: Site context set, no tenant
@spec confirm_tenant(Plug.Conn.t(), term()) :: Plug.Conn.t()
Confirms the tenant call/2 chose, once :load_from_session has read the session's user.
Plug it in straight after :load_from_session, imported from this module:
import MishkaCmsCore.Runtime.Plugs.SiteLookupPlug, only: [confirm_tenant: 2]
pipeline :browser do
# ...
plug MishkaCmsCore.Runtime.Plugs.SiteLookupPlug
plug :load_from_session
plug :confirm_tenant
endA request stays unscoped only for a master, a user whose site_id is nil, or for a visitor on the
master site's sign-in pages who asked for the master accounts. Anyone else on an unscoped request —
a user with a site, or one that carries no site_id at all — is signed out of it: :current_user becomes nil, the token leaves the session, the request is
scoped to the site, and a plugin on web:request:session_refused hears it with
%{site: site, user_id: id}. An unscoped request whose token did not verify is scoped to the site.
A user the site's closed sign-in signs out (MishkaCmsCore.Accounts.AuthGate.signs_out?/2) is
signed out everywhere, their session cleared, and sent to the front page with the reason.
Initializes the plug with configuration options.
Currently accepts no specific configuration options and returns an empty map. This allows for future extensibility without breaking existing implementations.
Parameters
opts- Plug initialization options (currently unused)
Returns
Empty map %{} for plug state
Examples
# Standard usage in router
plug MishkaCmsCore.Runtime.Plugs.SiteLookupPlug
# With explicit options (currently unused)
plug MishkaCmsCore.Runtime.Plugs.SiteLookupPlug, %{}
Whether segments — a request path split into its segments, as conn.path_info holds it — is an
address a sign-in happens on for site: /auth, /api/auth and every address under them, and
/<language>/auth and every address under it when <language> is one of the site's languages
(MishkaCmsCore.Runtime.Languages.site/1). Whole segments only, so /authors is a page like any
other. nil for site recognises only the addresses without a language.
Every rule that treats the sign-in pages apart asks this: the master accounts' unscoped sign-in
here, MishkaCms.Plugs.MaintenanceGate and MishkaCmsCore.Runtime.SiteAvailability keeping them
open on an offline site, and MishkaCmsWeb.RuntimeLive loading the app's own client on them.
SiteLookupPlug.sign_in_path?(["en", "auth", "register"], site) #=> true
SiteLookupPlug.sign_in_path?(["api", "auth", "user"], site) #=> true
SiteLookupPlug.sign_in_path?(["xx", "auth"], site) #=> false
SiteLookupPlug.sign_in_path?(["authors"], site) #=> false
The site a request's host names — the Host header without its port, as
MishkaCmsCore.Runtime.OriginValidator.host_name/1 reads it — as
MishkaCmsCore.Runtime.States.SiteState.get_by_host/1 answers.
In a build whose config sets :env_dev? or :env_test?, localhost and 127.0.0.1 are the
master site while no site has that host. A release has no such clause: there they name the site
that has them, as every other host does, or none.
This plug and MishkaCmsCore.Runtime.Plugs.ApiTenantLookupPlug both resolve a request's site
through it, so a page and an API call on the same address reach the same site.
SiteLookupPlug.site_for("blog.example.com") #=> {:ok, %{host: "blog.example.com", ...}}
SiteLookupPlug.site_for("localhost") #=> {:ok, %{master: true, ...}} in dev and test
SiteLookupPlug.site_for("unknown.example") #=> {:error, :not_found}