# `MishkaCmsCore.Runtime.Redirects`
[🔗](https://github.com/mishka-group/mishka_cms_core/blob/v0.0.1/lib/mishka_cms_core/runtime/redirects.ex#L5)

The redirect rules of each site, answered on the request that asks for an old address.

`MishkaCmsCore.Runtime.Redirect` is the table and `MishkaCmsCore.Runtime.States.RedirectsState`
the ETS copy every request reads; this module is what stands between them and the rest of the
system — the form both addresses are stored in, the rule that a chain of redirects never comes
back on itself, and the answer a matching request gets.

## Where a redirect is answered

`MishkaCmsCore.Runtime.Plugs.SiteLookupPlug` calls `redirect/2` the moment the request's site is
known, before the session is touched and before any page is resolved, so a rule answers every
address the `:browser` pipeline reaches — a page that moved, and a file `Plug.Static` did not find,
which falls through to the router's catch-all.

A live navigation inside an open socket reaches no plug; `lookup/3` answers it the same way, for
the LiveView that serves the site's pages to ask in `handle_params/3`.

## The event

A request answered by a rule is heard on `web:request:redirected`, with the conn already carrying
its status and `location` and `meta: %{site:, from:, to:, status:}`. A plugin may change the conn,
the `location` included, or halt it to answer the request itself.

# `lookup`

```elixir
@spec lookup(String.t(), String.t(), String.t()) :: {String.t(), pos_integer()} | nil
```

The rule `site_id` serves for `request_path`, as `{location, status}` — the target with the
request's `query_string` added when it has none of its own — or `nil`. Read from
`MishkaCmsCore.Runtime.States.RedirectsState`, with no query.

# `no_loop`

```elixir
@spec no_loop(Ash.Changeset.t(), map()) :: :ok | {:error, keyword()}
```

The validation every create and update of `MishkaCmsCore.Runtime.Redirect` runs: a rule that will
be served refuses a target that is its own source, and a target the site's other active rules lead
back from — to the source or to any address already on the way. A rule leaving the site, or for a
host `MishkaCmsCore.Runtime.States.SiteState` gives another site, ends the chain.

Checked when the rule is written active, or restored, and when its addresses or its `active`
change — as the action runs, not while a form is being typed. The other rules are read with the
action's own actor, tenant and authorization, so a check the system runs reads as the system.

# `normalize`

```elixir
@spec normalize(Ash.Changeset.t(), map()) :: Ash.Changeset.t()
```

The change every create and update of `MishkaCmsCore.Runtime.Redirect` runs: `from_path` through
`normalize_path/1` and `to` through `normalize_target/1`, each only when it is being written. A
value neither accepts is refused on its field, and so is a `from_path` that `reserved?/1`.

# `normalize_path`

```elixir
@spec normalize_path(String.t()) :: {:ok, String.t()} | :error
```

The address a rule answers, as it is stored and matched: one leading `/`, no scheme or host, no
query or fragment, no empty segment and so no trailing `/` but the root's, and every `%XX` escape
decoded. `:error` for what cannot be an address on a site: an empty value, whitespace or a control
character, a scheme other than `http` or `https`, or an escape that decodes to invalid UTF-8.

    normalize_path("https://mishka.tools/images/blog/a.jpg?w=1") #=> {:ok, "/images/blog/a.jpg"}
    normalize_path("old-page/")                                  #=> {:ok, "/old-page"}
    normalize_path("/fa/%D8%A8%D9%84%D8%A7%DA%AF")               #=> {:ok, "/fa/بلاگ"}
    normalize_path("/a b")                                       #=> :error

# `normalize_target`

```elixir
@spec normalize_target(String.t()) :: {:ok, String.t()} | :error
```

Where a rule may send a visitor, trimmed: a path on the site (`/x`, with a query or a fragment if
it has one) or an absolute `http(s)` URL with a host. `:error` for anything else — `//host`
included, which a browser reads as another host, and any `\`, which a browser reads as `/`, so
`/\host` is another host too.

# `permanent?`

```elixir
@spec permanent?(pos_integer()) :: boolean()
```

Whether `status` tells a browser and a search engine the move is for good: 301 and 308.

# `redirect`

```elixir
@spec redirect(Plug.Conn.t(), %{id: String.t()}) :: Plug.Conn.t()
```

Answers `conn` with the redirect `site` serves for its path, if any: the rule's status, the
`location` from `lookup/3`, and a halt. A conn no rule matches is returned untouched.

A plugin on `web:request:redirected` hears every one first — see the moduledoc.

# `reserved?`

```elixir
@spec reserved?(String.t()) :: boolean()
```

Whether `path` (normalized) is one of the CMS's own addresses, which no rule may take.

# `status_label`

```elixir
@spec status_label(pos_integer()) :: String.t()
```

The name of `status` in the reader's language, such as `301 Moved permanently`.

# `status_options`

```elixir
@spec status_options() :: [{String.t(), pos_integer()}]
```

The statuses as `{label, value}` pairs for a select, each label in the reader's language.

# `statuses`

```elixir
@spec statuses() :: [301 | 302 | 307 | 308]
```

The statuses a rule may answer with.

# `writes?`

```elixir
@spec writes?(map() | nil) :: boolean()
```

Whether `user` writes redirect rules from the dashboard: it holds
`admin:dashboard:runtime:redirect`, or a name above it. Which site's rules it writes is the
resource's policies' question.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
