MishkaCmsCore.Runtime.Plugs.SiteLookupPlug (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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

  1. Host Extraction: Extracts and normalizes host from request headers
  2. Site Lookup: Queries SiteState cache for host-to-site mapping
  3. Site Validation: Checks site active status
  4. 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
  5. Sign-in Pool: On the master site, ?master_user= picks the accounts its sign-in page uses
  6. Tenant Assignment: Sets Ash tenant context from the session's token
  7. 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
end

Masters

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

auth_address(language, site_id, under \\ "")

@spec auth_address(String.t() | nil, String.t() | nil, String.t()) :: String.t()

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"

call(conn, opts)

Performs site lookup based on Host header and manages site context.

This is the main plug function that orchestrates the entire site resolution workflow:

  1. Extracts and normalizes the host from request headers
  2. Queries SiteState for site information by host
  3. Validates site status and manages tenant context
  4. Stores site information in connection assigns and session

Parameters

  • conn - The Plug.Conn struct representing the HTTP connection
  • opts - Plug options (from init/1, currently unused)

Returns

Modified Plug.Conn with:

  • :current_site assign containing site data (if found and active)
  • :current_site session 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)}
end

Response Scenarios

  • Active Site Found: Connection continues with site context
  • Redirected Address: The rule's status and location, halted, with :current_site assigned
  • 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

confirm_tenant(conn, opts)

@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
end

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

init(opts)

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, %{}

sign_in_path?(segments, site)

@spec sign_in_path?([String.t()], %{id: String.t()} | nil) :: boolean()

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

site_for(host)

@spec site_for(String.t() | nil) :: {:ok, map()} | {:error, :not_found}

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}