MishkaCmsCore.Settings (Mishka CMS Core v0.0.1)

Copy Markdown View Source

CMS settings — one module for the whole subject: reading and writing values, plus the registry of declared settings the admin page renders.

Reading & writing

Reads come from the ETS cache (MishkaCmsCore.Runtime.States.SettingsState) — lock-free, safe on every request; writes go to the durable MishkaCmsCore.Runtime.Setting resource and refresh the cache. Scalars are stored wrapped as %{"value" => term}. Settings are multitenant: a site_id of nil is the GLOBAL value (shared by every site), a site_id is that site's override, and a site read resolves site value → global → default.

Settings.get("cms.offline", false)                 # global read
Settings.get("site.title", "MishkaCMS", site_id)   # site value, falling back to global
Settings.put("cms.offline", true)                  # global write
Settings.put("site.title", "Acme", site_id: id)    # per-site write

put/3 is the trusted infra path — boot seeding and plugin registration, where there is no user to speak for — and it skips authorization, multitenancy and the master check together. Anything an admin typed goes through put_as/4 instead, which routes to the resource's own :set / :master_set action with the actor attached, so MishkaCmsCore.Runtime.Setting's policies decide which site's row they may touch.

put/3 holds the resource's PubSub notifications back (return_notifications?: true), refreshes the cache, and only then sends them. The order matters: subscribers of "setting:updated" react by reading the cache, so announcing the write first hands every one of them the value it replaced.

Declared settings (the registry)

A definition is a plain map (see define/3) describing a key, its input type, default, and where it shows on the settings page. load_definitions/0 runs once at boot: it aggregates definitions from three sources, caches them in :persistent_term, and seeds any missing default into the store.

  1. Core — core_definitions/0 below.

  2. Extensions — modules listed in config :mishka_cms, :settings_providers, each exporting a definitions/0 returning a list of define/3 maps:

    # config/config.exs
    config :mishka_cms, :settings_providers, [MishkaBlog.Settings]
    
    defmodule MishkaBlog.Settings do
      def definitions do
        [MishkaCmsCore.Settings.define("blog.posts_per_page", :integer,
           default: 10, category: "blog", source: :extension, label: "Posts per page")]
      end
    end
  3. Runtime plugins — anything answering the settings:register mishka_installer hook (Hook.call("settings:register", %{definitions: [...]})).

Secrets

A :secret setting (a provider's API key) is stored sealed with the endpoint's secret_key_base (Phoenix.Token.encrypt/4), so the row, the cache, the "setting:updated" broadcast and the cms_core:setting:after_* plugin hooks carry only the sealed text. get/3 hands back that sealed text; secret/2 opens it, and secret_hint/2 gives the last four characters for the settings page, which never draws a stored secret into a field. Rotating secret_key_base makes stored secrets unreadable, and they are entered again.

See MishkaCmsCore.Runtime.Setting and MishkaCmsCore.Runtime.States.SettingsState.

Summary

Functions

All GLOBAL settings as %{key => scalar_value} (from the RAM cache) — used for default seeding.

Coerces a submitted value against the definition that declared it — the one gate both editors go through, so a number typed on the settings page and the same number picked in a dashboard card's gear are bounded identically.

The distinct categories, in first-seen order (the settings-page tabs).

Builds a setting definition map. opts: :default, :label, :description, :category (default "general", the settings-page TAB — usually the owning area/extension), :group (an optional sub-section HEADING within the tab, e.g. "Notifications"; nil = no heading), :source (:core | :extension | :plugin, default :core), :scope (:global one value for all sites, read-only to tenants | :site each tenant owns its own | :both a global default a site may override | :master global AND master-only, hidden from tenants; default :global), :editable? (default true), :options (choices for a :select), :attention_on_default (default false) — set it when the default is only a placeholder an admin is expected to replace, and the settings page marks the tab with a red dot until they do — :permission (default nil = admin:dashboard:setting; one name or a list of them, where holding ANY is enough) — what writing the setting takes, and who sees it on the settings page, e.g. "admin:dashboard:setting:deploy". A :user setting is seen by whoever holds its :permission (everyone when nil) and written by its user alone. A tab whose every setting is gated away disappears with them — and :validate (default nil), a one-argument function answering whether a value cast/2 has already typed may be stored, for a rule the type alone does not hold: site.languages is a list of strings, and only language codes — :resolve (default :override, a site's value replaces the global one), set to :any for a boolean whose global and site values are OR-ed, as cms.offline's are: a site's view then shows the site's own value and says when the global one holds it on, or :own for a value each scope sets for itself and never takes from the global one, as a site's email provider and its key are (read it with resolved/3) — and :global_label (default nil, the :label everywhere), the label in the Global view when it reads differently there — and :visible (default true), or fn values -> boolean end over the tab's values as the admin has them on screen (key → value, typed or saved), for a field that only means something beside another one's choice: a provider's keys while that provider is chosen. The settings page draws and saves it only while it holds; see visible?/2 and shown_when/3.

The registered definition for a key, or nil.

All registered definitions, in the order they were declared — core first, then config providers, then runtime plugins, each key keeping the position it was first declared at while a later redeclaration of the same key still wins on content.

Registered definitions in a category (a settings-page tab).

Read a setting's scalar value from the RAM cache, or default if unset. With a site_id the read resolves the site's override first, then the global value, then default.

A site's own value for a setting, or default when the site holds none. Unlike get/3 it never falls back to the global value — it is what the site itself set. nil for the site is the installation's own value: the global one.

Read a PER-USER preference (:user scope) — a value only that user sees, stored under a user-namespaced key so it never touches the site/global value. default when unset.

Aggregates every definition (core + config providers + the settings:register hook), caches them in :persistent_term, and seeds any missing default into the store. Run once at boot; returns the definitions.

Whether a site is serving a holding page instead of itself.

Which switches hold a site offline: :global for the one every site answers to, :site for the site's own. [] when it is online; nil asks about the global switch alone.

Whether user may see something that declares a :permission — a setting definition, or anything else carrying the field, such as a dashboard widget's descriptor.

Creates or updates a setting (durable), refreshes the RAM cache from the committed row, and notifies plugins via the cms_core:setting:after_master_set hook. Options: :site_id (default nil ⇒ global; a uuid ⇒ that site's override), :category (default "general"), :source (:core | :extension | :plugin, default :core), :description, :editable? (default true). Returns {:ok, value} or {:error, changeset}.

Writes a setting as a user, through the resource's own tenant/master action with the actor attached and authorization ON — the path every admin-facing editor takes. put/3 stays the trusted infra path (boot seeding, plugin registration) and deliberately skips all three of authorization, multitenancy and the master check; this one skips none of them.

Write a per-user preference (see get_user/3). Returns {:ok, value} | {:error, changeset}.

A setting's value for site_id the way its definition resolves it: the scope's own value for resolve: :own (see get_own/3), else get/3's site → global → default.

A :secret setting opened for site_id, resolved like resolved/3; nil when none is stored or it cannot be opened. See "Secrets" above.

What the settings page may say about a stored :secret for site_id: its last four characters; :unreadable when one is stored but was sealed under another SECRET_KEY_BASE — the server's key changed since — so it must be typed again; nil when none is stored.

definition, shown only while the setting key holds value — and while its own :visible holds. How a provider's fields follow the provider select: each provider's settings pass through it, so no provider has to say so itself.

A definition's :label, :description, :group, :global_label or option label, in the reader's language.

Whether definition shows beside the tab's values (key → value): its :visible, true by default.

Whether user may write the setting definition declares (write_permission/1). A definition of scope: :user is the user's own preference, and is theirs to write whenever they may see it (permitted?/2).

What writing a setting takes, as a list where holding ANY entry is enough: the :permission its definition declares, or admin:dashboard:setting when it declares none. Takes a definition, or a key. A key no definition names takes admin:dashboard:setting too, except the builder's own — its Studio switches (studio.*) and component presets (builder:preset:*) — which take any name that opens a builder: a runtime area of a document the builder edits, or admin:dashboard:code.

Types

definition()

@type definition() :: %{
  key: String.t(),
  type: input_type(),
  default: term(),
  label: String.t() | nil,
  description: String.t() | nil,
  category: String.t(),
  group: String.t() | nil,
  source: :core | :extension | :plugin,
  scope: scope(),
  editable?: boolean(),
  options: [String.t()],
  min: integer() | nil,
  max: integer() | nil,
  attention_on_default: boolean(),
  permission:
    MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement()
    | [MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement()]
    | nil,
  validate: (term() -> boolean()) | nil,
  resolve: :override | :any | :own,
  global_label: String.t() | nil,
  visible: boolean() | (map() -> boolean()),
  collapsed: boolean(),
  layout: :table | nil,
  inverted: boolean(),
  under: String.t() | nil,
  trim: boolean()
}

input_type()

@type input_type() ::
  :string | :text | :boolean | :integer | :select | :list | :secret | :image

scope()

@type scope() :: :global | :site | :both | :master | :user

Functions

all_global()

@spec all_global() :: %{optional(String.t()) => term()}

All GLOBAL settings as %{key => scalar_value} (from the RAM cache) — used for default seeding.

cast(definition, value)

@spec cast(definition() | map(), term()) :: {:ok, term()} | :error

Coerces a submitted value against the definition that declared it — the one gate both editors go through, so a number typed on the settings page and the same number picked in a dashboard card's gear are bounded identically.

:integer parses and clamps to the definition's :min/:max; :boolean reads the form's "on"/"true" (and a missing checkbox as false); :select must name one of the declared :options, given either as bare values or {label, value} pairs; :string/:text take any binary; :list takes a list of strings or the comma-separated text the settings page sends, each entry trimmed and blanks and repeats dropped. Anything else — an unparsable number, a value off a select's list, a list holding something other than strings, an unknown type — is :error, and so is a value of the right type that the definition's :validate answers false for. Nothing is coerced to a silent zero or nil: a value that cannot be trusted is refused and the caller decides what to tell the admin.

categories()

@spec categories() :: [String.t()]

The distinct categories, in first-seen order (the settings-page tabs).

define(key, type, opts \\ [])

@spec define(String.t(), input_type(), keyword()) :: definition()

Builds a setting definition map. opts: :default, :label, :description, :category (default "general", the settings-page TAB — usually the owning area/extension), :group (an optional sub-section HEADING within the tab, e.g. "Notifications"; nil = no heading), :source (:core | :extension | :plugin, default :core), :scope (:global one value for all sites, read-only to tenants | :site each tenant owns its own | :both a global default a site may override | :master global AND master-only, hidden from tenants; default :global), :editable? (default true), :options (choices for a :select), :attention_on_default (default false) — set it when the default is only a placeholder an admin is expected to replace, and the settings page marks the tab with a red dot until they do — :permission (default nil = admin:dashboard:setting; one name or a list of them, where holding ANY is enough) — what writing the setting takes, and who sees it on the settings page, e.g. "admin:dashboard:setting:deploy". A :user setting is seen by whoever holds its :permission (everyone when nil) and written by its user alone. A tab whose every setting is gated away disappears with them — and :validate (default nil), a one-argument function answering whether a value cast/2 has already typed may be stored, for a rule the type alone does not hold: site.languages is a list of strings, and only language codes — :resolve (default :override, a site's value replaces the global one), set to :any for a boolean whose global and site values are OR-ed, as cms.offline's are: a site's view then shows the site's own value and says when the global one holds it on, or :own for a value each scope sets for itself and never takes from the global one, as a site's email provider and its key are (read it with resolved/3) — and :global_label (default nil, the :label everywhere), the label in the Global view when it reads differently there — and :visible (default true), or fn values -> boolean end over the tab's values as the admin has them on screen (key → value, typed or saved), for a field that only means something beside another one's choice: a provider's keys while that provider is chosen. The settings page draws and saves it only while it holds; see visible?/2 and shown_when/3.

:trim (default false) strips the whitespace around a :string or :text before it is checked and stored.

:collapsed (default false) makes the setting's :group an accordion on the settings page: a group with any shown setting that declares it is drawn as a closed <details> titled by the group, whatever its settings hold. It opens by itself only when the last save refused a value of one of its settings, so the message under the field is in view, and otherwise stays as the admin left it. It needs a :group, whose heading it becomes, and works for any tab, so an extension's optional settings can stay out of the way until they are used. The accordion stands where the group's first setting is declared:

Settings.define("site.theme_color", :string,
  group: gettext_noop("App & home screen"), collapsed: true, default: "", scope: :site)

:layout (default nil) set to :table draws the setting's :group as one table, a row per setting — for a group of switches read together, such as the ways of signing in. Like :collapsed, one setting declaring it is enough.

:under (default nil), in a :table group, names the switch whose row the setting is drawn in, below that switch's description, rather than as a row of its own — a provider's keys under the provider. Pair it with shown_when/3 on that switch to draw it only while the switch is on.

:inverted (default false) draws a :boolean as its opposite: the switch is on while the stored value is false. A *.closed key reads as the way it closes, on while open, beside switches that are on while on.

definition(key)

@spec definition(String.t()) :: definition() | nil

The registered definition for a key, or nil.

definitions()

@spec definitions() :: [definition()]

All registered definitions, in the order they were declared — core first, then config providers, then runtime plugins, each key keeping the position it was first declared at while a later redeclaration of the same key still wins on content.

The order is the settings page's tab order, and handle_params falls back to the FIRST category when no tab is named, so it has to be stable. It was not: the registry was a plain map and this returned Map.values/1, so both the tab order and which tab opened by default were map iteration order — stable only by luck, and reshuffling as soon as enough definitions were registered.

definitions(category)

@spec definitions(String.t()) :: [definition()]

Registered definitions in a category (a settings-page tab).

get(key, default \\ nil, site_id \\ nil)

@spec get(String.t(), term(), term()) :: term()

Read a setting's scalar value from the RAM cache, or default if unset. With a site_id the read resolves the site's override first, then the global value, then default.

get_own(key, default, site_id)

@spec get_own(String.t(), term(), String.t() | nil) :: term()

A site's own value for a setting, or default when the site holds none. Unlike get/3 it never falls back to the global value — it is what the site itself set. nil for the site is the installation's own value: the global one.

get_user(key, user_id, default \\ nil)

@spec get_user(String.t(), term(), term()) :: term()

Read a PER-USER preference (:user scope) — a value only that user sees, stored under a user-namespaced key so it never touches the site/global value. default when unset.

load_definitions()

@spec load_definitions() :: [definition()]

Aggregates every definition (core + config providers + the settings:register hook), caches them in :persistent_term, and seeds any missing default into the store. Run once at boot; returns the definitions.

offline?(site)

@spec offline?(map() | String.t() | nil) :: boolean()

Whether a site is serving a holding page instead of itself.

Takes a site struct, a site id, or nil for "no site resolved". Two ETS reads, no query.

Settings.offline?(conn.assigns[:current_site])

cms.offline is the one setting that does not resolve the usual way. Everywhere else a site's value replaces the global one; here the two are OR-ed, so a master switching every site off cannot be undone by a site switching itself on. A site's own value only ever adds to it.

globalsiteresult
offoffonline
offonoffline
onoffoffline
onunsetoffline

offline_by(site)

@spec offline_by(map() | String.t() | nil) :: [:global | :site]

Which switches hold a site offline: :global for the one every site answers to, :site for the site's own. [] when it is online; nil asks about the global switch alone.

Settings.offline_by(site)   # => [:global, :site]

Two ETS reads, no query. offline?/1 is offline_by/1 != [].

permitted?(arg1, user)

@spec permitted?(map(), map() | nil) :: boolean()

Whether user may see something that declares a :permission — a setting definition, or anything else carrying the field, such as a dashboard widget's descriptor.

One entry or a list of them, where holding ANY is enough — a name, or {:under, prefix} for anyone holding anything under prefix (MishkaCmsCore.Accounts.Checks.AccessPermissions). nil and [] gate nothing and mean everyone who got this far. Nobody (a nil user) is never permitted, whatever is declared — an absent user is not an anonymous one with open access.

put(key, value, opts \\ [])

@spec put(String.t(), term(), keyword()) :: {:ok, term()} | {:error, term()}

Creates or updates a setting (durable), refreshes the RAM cache from the committed row, and notifies plugins via the cms_core:setting:after_master_set hook. Options: :site_id (default nil ⇒ global; a uuid ⇒ that site's override), :category (default "general"), :source (:core | :extension | :plugin, default :core), :description, :editable? (default true). Returns {:ok, value} or {:error, changeset}.

put_as(actor, key, value, opts \\ [])

@spec put_as(map(), String.t(), term(), keyword()) :: {:ok, term()} | {:error, term()}

Writes a setting as a user, through the resource's own tenant/master action with the actor attached and authorization ON — the path every admin-facing editor takes. put/3 stays the trusted infra path (boot seeding, plugin registration) and deliberately skips all three of authorization, multitenancy and the master check; this one skips none of them.

A master (site_id nil) writes the global row or any site's, via :master_set. A tenant writes its own site's row via :set, and naming a different site is refused by the resource's policies rather than clamped here — the caller finds out it asked for something it may not have, instead of quietly getting something else. opts are put/3's, where :site_id names the target row (a tenant omitting it means its own site).

Returns {:ok, value} or {:error, reason}; anything without a :site_id key — nil included — is {:error, :no_actor}, never a silent write.

put_user(key, value, user_id)

@spec put_user(String.t(), term(), term()) :: {:ok, term()} | {:error, term()}

Write a per-user preference (see get_user/3). Returns {:ok, value} | {:error, changeset}.

resolved(key, default, site_id)

@spec resolved(String.t(), term(), String.t() | nil) :: term()

A setting's value for site_id the way its definition resolves it: the scope's own value for resolve: :own (see get_own/3), else get/3's site → global → default.

secret(key, site_id)

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

A :secret setting opened for site_id, resolved like resolved/3; nil when none is stored or it cannot be opened. See "Secrets" above.

secret_hint(key, site_id)

@spec secret_hint(String.t(), String.t() | nil) :: String.t() | :unreadable | nil

What the settings page may say about a stored :secret for site_id: its last four characters; :unreadable when one is stored but was sealed under another SECRET_KEY_BASE — the server's key changed since — so it must be typed again; nil when none is stored.

shown_when(definition, key, value)

@spec shown_when(definition(), String.t(), term()) :: definition()

definition, shown only while the setting key holds value — and while its own :visible holds. How a provider's fields follow the provider select: each provider's settings pass through it, so no provider has to say so itself.

Settings.shown_when(definition, "mailer.provider", "resend")

text(msgid)

@spec text(String.t() | {String.t(), keyword() | map()} | nil) :: String.t() | nil

A definition's :label, :description, :group, :global_label or option label, in the reader's language.

Definitions are cached at boot, before anybody's language is known, so each holds its English words marked with gettext_noop/1, and the settings page translates them as it draws them. A text built from another is {msgid, bindings}, each binding translated in turn:

Settings.text(gettext_noop("Theme"))                                   #=> "پوسته"
Settings.text({gettext_noop("%{label} — position"), label: "Blog"})    #=> "وبلاگ — جایگاه"

visible?(arg1, values)

@spec visible?(definition() | map(), map()) :: boolean()

Whether definition shows beside the tab's values (key → value): its :visible, true by default.

writable?(definition, user)

@spec writable?(definition(), map() | nil) :: boolean()

Whether user may write the setting definition declares (write_permission/1). A definition of scope: :user is the user's own preference, and is theirs to write whenever they may see it (permitted?/2).

write_permission(key)

@spec write_permission(definition() | map() | String.t() | nil) :: [
  MishkaCmsCore.Accounts.Checks.AccessPermissions.requirement()
]

What writing a setting takes, as a list where holding ANY entry is enough: the :permission its definition declares, or admin:dashboard:setting when it declares none. Takes a definition, or a key. A key no definition names takes admin:dashboard:setting too, except the builder's own — its Studio switches (studio.*) and component presets (builder:preset:*) — which take any name that opens a builder: a runtime area of a document the builder edits, or admin:dashboard:code.

Settings.write_permission("site.title")          #=> ["admin:dashboard:setting"]
Settings.write_permission("studio.lock_tokens")  #=> ["admin:dashboard:runtime:page", …]