init
This commit is contained in:
126
lib/forum/admin.ex
Normal file
126
lib/forum/admin.ex
Normal file
@@ -0,0 +1,126 @@
|
||||
defmodule Forum.Admin do
|
||||
@moduledoc """
|
||||
HTML fragments served to the HTMX admin page (/admin_htmx). Two
|
||||
panels (processes, modules) plus their row-only partials.
|
||||
"""
|
||||
|
||||
# ── processes panel ───────────────────────────────────────────────
|
||||
|
||||
def processes_panel do
|
||||
"""
|
||||
<section>
|
||||
<form class="toolbar"
|
||||
hx-get="/admin_htmx/processes/rows"
|
||||
hx-target="#p-rows"
|
||||
hx-trigger="change">
|
||||
<label><input type="checkbox" name="mine"> mine</label>
|
||||
<span class="htmx-indicator">…</span>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>pid</th><th>name</th><th>initial call</th>
|
||||
<th class="num">memory (KB)</th><th class="num">msgs</th><th>status</th>
|
||||
</tr></thead>
|
||||
<tbody id="p-rows"
|
||||
hx-get="/admin_htmx/processes/rows"
|
||||
hx-trigger="load, every 5s"
|
||||
hx-include="closest section form"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
def processes_rows(rows) do
|
||||
rows
|
||||
|> build_tree()
|
||||
|> render_tree(0)
|
||||
|> IO.iodata_to_binary()
|
||||
end
|
||||
|
||||
defp build_tree(rows) do
|
||||
by_parent = Enum.group_by(rows, & &1["parent"])
|
||||
Map.get(by_parent, nil, []) |> Enum.map(&attach_children(&1, by_parent))
|
||||
end
|
||||
|
||||
defp attach_children(node, by_parent) do
|
||||
children = Map.get(by_parent, node["pid"], [])
|
||||
Map.put(node, "children", Enum.map(children, &attach_children(&1, by_parent)))
|
||||
end
|
||||
|
||||
defp render_tree(nodes, depth) do
|
||||
Enum.map(nodes, fn node ->
|
||||
[process_row(node, depth) | render_tree(node["children"], depth + 1)]
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_row(node, depth) do
|
||||
row_cls = if node["mine"], do: ~s| class="mine"|, else: ""
|
||||
pad = 8 + depth * 16
|
||||
name = node["name"]
|
||||
name_cls = if name, do: ~s| class="name"|, else: ""
|
||||
|
||||
[
|
||||
~s|<tr#{row_cls}>|,
|
||||
~s|<td style="padding-left:#{pad}px">#{h(node["pid"])}</td>|,
|
||||
~s|<td#{name_cls}>#{h(name || "-")}</td>|,
|
||||
~s|<td>#{h(node["initial_call"])}</td>|,
|
||||
~s|<td class="num">#{node["memory_kb"]}</td>|,
|
||||
~s|<td class="num">#{node["msgs"]}</td>|,
|
||||
~s|<td>#{h(node["status"])}</td>|,
|
||||
"</tr>"
|
||||
]
|
||||
end
|
||||
|
||||
# ── modules panel ─────────────────────────────────────────────────
|
||||
|
||||
def modules_panel do
|
||||
"""
|
||||
<section>
|
||||
<form class="toolbar"
|
||||
hx-get="/admin_htmx/modules/rows"
|
||||
hx-target="#m-rows"
|
||||
hx-trigger="change, input changed delay:200ms from:input[name=q]">
|
||||
<label><input type="checkbox" name="mine"> mine</label>
|
||||
<input type="text" name="q" placeholder="filter…" autocomplete="off">
|
||||
<span class="htmx-indicator">…</span>
|
||||
</form>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>module</th><th>app</th><th>source</th>
|
||||
</tr></thead>
|
||||
<tbody id="m-rows"
|
||||
hx-get="/admin_htmx/modules/rows"
|
||||
hx-trigger="load"
|
||||
hx-include="closest section form"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
def modules_rows(rows) do
|
||||
Enum.map_join(rows, "", &module_row/1)
|
||||
end
|
||||
|
||||
defp module_row(r) do
|
||||
row_cls = if r["mine"], do: ~s| class="mine"|, else: ""
|
||||
|
||||
~s|<tr#{row_cls}>| <>
|
||||
~s|<td class="mod">#{h(r["module"])}</td>| <>
|
||||
~s|<td>#{h(r["app"] || "-")}</td>| <>
|
||||
~s|<td class="src">#{h(r["source"] || "(no source)")}</td>| <>
|
||||
"</tr>"
|
||||
end
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────
|
||||
|
||||
defp h(text) when is_binary(text) do
|
||||
text
|
||||
|> String.replace("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|> String.replace("\"", """)
|
||||
|> String.replace("'", "'")
|
||||
end
|
||||
|
||||
defp h(other), do: other |> to_string() |> h()
|
||||
end
|
||||
72
lib/forum/application.ex
Normal file
72
lib/forum/application.ex
Normal file
@@ -0,0 +1,72 @@
|
||||
# lib/forum/application.ex
|
||||
defmodule Forum.Application do
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
db_config = Application.fetch_env!(:forum, Forum.DB)
|
||||
server_config = Application.fetch_env!(:forum, :server)
|
||||
|
||||
children =
|
||||
[
|
||||
{Postgrex, [name: Forum.DB] ++ db_config},
|
||||
|
||||
# Unique process names for forms, networks, public sites, and HTTP servers.
|
||||
{Registry, keys: :unique, name: Forum.ProcessRegistry},
|
||||
|
||||
# WebSocket clients subscribe here for live forms.html updates.
|
||||
{Registry, keys: :duplicate, name: Forum.FormsSubscriberRegistry},
|
||||
|
||||
Forum.LogStore,
|
||||
Forum.Networks,
|
||||
Forum.PublicSiteSupervisor,
|
||||
|
||||
# Parses forms.html and supervises one QuickBEAM runtime per
|
||||
# <forms-> child element. Also serves Forum.Forms.html/0.
|
||||
Forum.Forms,
|
||||
|
||||
# Re-parses forms.html whenever the file changes on disk.
|
||||
Forum.Forms.Watcher,
|
||||
|
||||
# Main HTTP + WebSocket server.
|
||||
http_server_child(:main, Forum.Router, server_config[:port])
|
||||
] ++ network_site_servers(server_config)
|
||||
|
||||
with {:ok, sup} <- Supervisor.start_link(children, strategy: :one_for_one, name: Forum.Supervisor) do
|
||||
# DynamicSupervisor can't start children from its own init/1, so
|
||||
# we kick off the initial parse + spawn here.
|
||||
Forum.Forms.reload()
|
||||
Forum.Networks.start_networks()
|
||||
Forum.PublicSiteSupervisor.start_networks()
|
||||
{:ok, sup}
|
||||
end
|
||||
end
|
||||
|
||||
defp network_site_servers(server_config) do
|
||||
base_port = Keyword.get(server_config, :network_port_base, 4001)
|
||||
|
||||
Forum.Networks.network_slugs()
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {network, index} ->
|
||||
http_server_child(
|
||||
{:network, network},
|
||||
{Forum.PublicSiteRouter, network: network},
|
||||
base_port + index
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
defp http_server_child(key, plug, port) do
|
||||
Supervisor.child_spec(
|
||||
{Bandit,
|
||||
plug: plug,
|
||||
port: port,
|
||||
thousand_island_options: [
|
||||
supervisor_options: [
|
||||
name: {:via, Registry, {Forum.ProcessRegistry, {:http_server, key}}}
|
||||
]
|
||||
]},
|
||||
id: {:http_server, key, port}
|
||||
)
|
||||
end
|
||||
end
|
||||
23
lib/forum/assets.ex
Normal file
23
lib/forum/assets.ex
Normal file
@@ -0,0 +1,23 @@
|
||||
defmodule Forum.Assets do
|
||||
@moduledoc false
|
||||
|
||||
def path(relative_path) do
|
||||
source_path = Path.expand(Path.join("priv", relative_path), File.cwd!())
|
||||
|
||||
if File.exists?(source_path) do
|
||||
source_path
|
||||
else
|
||||
Application.app_dir(:forum, Path.join("priv", relative_path))
|
||||
end
|
||||
end
|
||||
|
||||
def paths(relative_glob) do
|
||||
source_pattern = Path.expand(Path.join("priv", relative_glob), File.cwd!())
|
||||
release_pattern = Application.app_dir(:forum, Path.join("priv", relative_glob))
|
||||
|
||||
case Path.wildcard(source_pattern) do
|
||||
[] -> Path.wildcard(release_pattern)
|
||||
paths -> paths
|
||||
end
|
||||
end
|
||||
end
|
||||
44
lib/forum/db.ex
Normal file
44
lib/forum/db.ex
Normal file
@@ -0,0 +1,44 @@
|
||||
defmodule Forum.DB do
|
||||
@moduledoc """
|
||||
Thin wrappers around `Postgrex.query/3`. No ORM, just SQL.
|
||||
The `Forum.DB` name was registered when Postgrex started in `Application`.
|
||||
"""
|
||||
|
||||
def query(sql, params \\ []) do
|
||||
Postgrex.query(__MODULE__, sql, params)
|
||||
end
|
||||
|
||||
def query!(sql, params \\ []) do
|
||||
Postgrex.query!(__MODULE__, sql, params)
|
||||
end
|
||||
|
||||
def list_messages(opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 50)
|
||||
|
||||
%Postgrex.Result{rows: rows, columns: cols} =
|
||||
query!("SELECT id, text, inserted_at FROM messages ORDER BY id DESC LIMIT $1", [limit])
|
||||
|
||||
rows_to_maps(rows, cols)
|
||||
end
|
||||
|
||||
def insert_message(text) when is_binary(text) do
|
||||
%Postgrex.Result{rows: [row], columns: cols} =
|
||||
query!(
|
||||
"INSERT INTO messages (text) VALUES ($1) RETURNING id, text, inserted_at",
|
||||
[text]
|
||||
)
|
||||
|
||||
[map] = rows_to_maps([row], cols)
|
||||
map
|
||||
end
|
||||
|
||||
# Postgrex returns rows as lists of values + a separate list of column names.
|
||||
# Zip them into maps so JSON encoding is straightforward.
|
||||
defp rows_to_maps(rows, cols) do
|
||||
Enum.map(rows, fn row ->
|
||||
cols
|
||||
|> Enum.zip(row)
|
||||
|> Map.new()
|
||||
end)
|
||||
end
|
||||
end
|
||||
300
lib/forum/forms.ex
Normal file
300
lib/forum/forms.ex
Normal file
@@ -0,0 +1,300 @@
|
||||
defmodule Forum.Forms do
|
||||
@moduledoc """
|
||||
Owns the parsed forms document and supervises one QuickBEAM runtime
|
||||
per element. `DynamicSupervisor` so the tree can change at runtime.
|
||||
|
||||
State (parsed Floki tree) lives in the ETS table `:forum_forms`,
|
||||
owned by the supervisor process. Reads are direct ETS lookups — no
|
||||
GenServer round-trip — and the table dies cleanly with the supervisor.
|
||||
|
||||
Public lifecycle:
|
||||
Forum.Forms.path/0 — path to the root forms.html
|
||||
Forum.Forms.reload/0 — re-read priv/forms.html and network forms
|
||||
Forum.Forms.set_html/1 — replace HTML directly and reconcile
|
||||
Forum.Forms.add_form/2 — start one runtime ad-hoc
|
||||
Forum.Forms.remove_form/2 — stop the runtime keyed by {class, id}
|
||||
Forum.Forms.reconcile/0 — diff stored elements vs running runtimes
|
||||
Forum.Forms.html/0 — rendered root HTML generated from the Floki tree
|
||||
Forum.Forms.html/1 — rendered network HTML generated from that network tree
|
||||
Forum.Forms.tree/0 — parsed Floki tree
|
||||
Forum.Forms.elements/0 — descriptor maps for <forms-> children
|
||||
"""
|
||||
use DynamicSupervisor
|
||||
require Logger
|
||||
|
||||
@table :forum_forms
|
||||
|
||||
# ── boot ──────────────────────────────────────────────────────────
|
||||
|
||||
def start_link(_opts) do
|
||||
DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(:ok) do
|
||||
:ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
|
||||
DynamicSupervisor.init(strategy: :one_for_one)
|
||||
end
|
||||
|
||||
# ── reads ─────────────────────────────────────────────────────────
|
||||
|
||||
def html do
|
||||
render_tree(root_tree())
|
||||
end
|
||||
|
||||
def html(network_slug) when is_binary(network_slug) do
|
||||
network_slug
|
||||
|> network_tree()
|
||||
|> render_tree()
|
||||
end
|
||||
|
||||
def tree do
|
||||
case :ets.lookup(@table, :tree) do
|
||||
[{:tree, tree}] -> tree
|
||||
[] -> []
|
||||
end
|
||||
end
|
||||
|
||||
def root_tree do
|
||||
case :ets.lookup(@table, :root_tree) do
|
||||
[{:root_tree, tree}] -> tree
|
||||
[] -> []
|
||||
end
|
||||
end
|
||||
|
||||
def network_tree(network_slug) do
|
||||
case :ets.lookup(@table, {:network_tree, network_slug}) do
|
||||
[{{:network_tree, ^network_slug}, tree}] -> tree
|
||||
[] -> root_tree()
|
||||
end
|
||||
end
|
||||
|
||||
def elements do
|
||||
tree()
|
||||
|> Floki.find("forms- > *")
|
||||
|> Enum.map(&to_descriptor/1)
|
||||
end
|
||||
|
||||
def path do
|
||||
Forum.Assets.path("forms.html")
|
||||
end
|
||||
|
||||
def network_paths do
|
||||
"networks/*/index.html"
|
||||
|> Forum.Assets.paths()
|
||||
|> Enum.map(fn path -> {path |> Path.dirname() |> Path.basename(), path} end)
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
# ── mutations ─────────────────────────────────────────────────────
|
||||
|
||||
@doc "Re-read priv/forms.html and priv/networks/*/index.html, store them, reconcile runtimes."
|
||||
def reload do
|
||||
root_tree = path() |> File.read!() |> Floki.parse_fragment!()
|
||||
|
||||
network_trees =
|
||||
Enum.map(network_paths(), fn {slug, path} ->
|
||||
{slug, path |> File.read!() |> Floki.parse_fragment!()}
|
||||
end)
|
||||
|
||||
all_elements =
|
||||
[root_tree | Enum.map(network_trees, fn {_slug, tree} -> tree end)]
|
||||
|> Enum.flat_map(&Floki.find(&1, "forms- > *"))
|
||||
|
||||
:ets.insert(@table, [
|
||||
{:tree, wrap_forms(all_elements)},
|
||||
{:root_tree, root_tree}
|
||||
| Enum.map(network_trees, fn {slug, tree} -> {{:network_tree, slug}, tree} end)
|
||||
])
|
||||
|
||||
reconcile()
|
||||
end
|
||||
|
||||
@doc "Replace the canonical HTML with `raw` and reconcile runtimes."
|
||||
def set_html(raw) when is_binary(raw) do
|
||||
tree = Floki.parse_fragment!(raw)
|
||||
:ets.insert(@table, [{:tree, tree}, {:root_tree, tree}])
|
||||
reconcile()
|
||||
end
|
||||
|
||||
@doc "Start one supervised form runtime under `{class_name, id}`."
|
||||
def add_form(class_name, data) when is_binary(class_name) and is_map(data) do
|
||||
DynamicSupervisor.start_child(__MODULE__, child_spec_for(class_name, data))
|
||||
end
|
||||
|
||||
@doc "Stop the form runtime registered under `{class_name, id}`."
|
||||
def remove_form(class_name, id) do
|
||||
case Registry.lookup(Forum.ProcessRegistry, {:form, {class_name, id}}) do
|
||||
[{pid, _}] -> DynamicSupervisor.terminate_child(__MODULE__, pid)
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# ── reconciliation ────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Diffs stored elements against the currently-running form runtimes:
|
||||
stops runtimes for elements that disappeared, starts runtimes for
|
||||
elements that appeared. Same `{class, id}` keeps its existing
|
||||
runtime even if the element's data changed — call `remove_form/2 +
|
||||
add_form/2` (or set new HTML containing a different id) to force a
|
||||
refresh.
|
||||
"""
|
||||
def reconcile do
|
||||
desired =
|
||||
Map.new(elements(), fn el ->
|
||||
class = pascal_case(el["tag"])
|
||||
data = Map.put_new(el["attrs"], "content", el["text"])
|
||||
id = Map.get(data, "id") || :erlang.unique_integer([:positive])
|
||||
{{class, id}, {class, data}}
|
||||
end)
|
||||
|
||||
current_keys = MapSet.new(current_form_keys())
|
||||
desired_keys = MapSet.new(Map.keys(desired))
|
||||
|
||||
for {class, id} <- MapSet.difference(current_keys, desired_keys) do
|
||||
remove_form(class, id)
|
||||
end
|
||||
|
||||
for key <- MapSet.difference(desired_keys, current_keys) do
|
||||
{class, data} = Map.fetch!(desired, key)
|
||||
add_form(class, data)
|
||||
end
|
||||
|
||||
Logger.info("Forum.Forms: reconciled, target=#{map_size(desired)} runtimes")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp current_form_keys do
|
||||
for {_id, pid, _type, _mods} <- DynamicSupervisor.which_children(__MODULE__),
|
||||
is_pid(pid),
|
||||
{:form, key} <- Registry.keys(Forum.ProcessRegistry, pid),
|
||||
do: key
|
||||
end
|
||||
|
||||
# ── child spec ────────────────────────────────────────────────────
|
||||
|
||||
defp child_spec_for(class_name, data) do
|
||||
id = Map.get(data, "id") || :erlang.unique_integer([:positive])
|
||||
name = {:via, Registry, {Forum.ProcessRegistry, {:form, {class_name, id}}}}
|
||||
|
||||
{QuickBEAM,
|
||||
name: name,
|
||||
id: {class_name, id},
|
||||
define: %{"className" => class_name, "formData" => data},
|
||||
script: Forum.Assets.path("js/form_init.js")}
|
||||
end
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────
|
||||
|
||||
defp wrap_forms(elements), do: [{"forms-", [], elements}]
|
||||
|
||||
defp render_tree([]), do: ""
|
||||
|
||||
defp render_tree(tree) do
|
||||
tree
|
||||
|> Floki.find("forms- > *")
|
||||
|> Enum.map(&to_descriptor/1)
|
||||
|> Enum.group_by(& &1["tag"])
|
||||
|> Enum.map_join("\n", fn {tag, descriptors} -> render_table(tag, descriptors) end)
|
||||
end
|
||||
|
||||
defp render_table(tag, descriptors) do
|
||||
tag_name = String.trim_trailing(tag, "-")
|
||||
columns = columns_for(descriptors)
|
||||
header = Enum.map_join(columns, "\n", &render_header/1)
|
||||
rows = Enum.map_join(descriptors, "\n", &render_row(&1, columns))
|
||||
|
||||
"""
|
||||
<section class="form-table" data-form-tag="#{escape_attr(tag_name)}">
|
||||
<h2><code><#{escape_html(tag_name)}-></code></h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
#{header}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
#{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
|> String.trim()
|
||||
end
|
||||
|
||||
defp render_header(column) do
|
||||
" <th>#{escape_html(column)}</th>"
|
||||
end
|
||||
|
||||
defp render_row(descriptor, columns) do
|
||||
attrs = descriptor["attrs"]
|
||||
|
||||
cells =
|
||||
Enum.map_join(columns, "\n", fn column ->
|
||||
value = if column == "content", do: descriptor["text"], else: Map.get(attrs, column, "")
|
||||
" <td>#{escape_html(value)}</td>"
|
||||
end)
|
||||
|
||||
"""
|
||||
<tr>
|
||||
#{cells}
|
||||
</tr>
|
||||
"""
|
||||
|> String.trim_trailing()
|
||||
end
|
||||
|
||||
defp columns_for(descriptors) do
|
||||
attr_columns =
|
||||
descriptors
|
||||
|> Enum.flat_map(fn descriptor -> Map.keys(descriptor["attrs"]) end)
|
||||
|> Enum.uniq()
|
||||
|
||||
content_columns =
|
||||
if Enum.any?(descriptors, &(&1["text"] != "")), do: ["content"], else: []
|
||||
|
||||
sort_columns(attr_columns) ++ content_columns
|
||||
end
|
||||
|
||||
defp sort_columns(columns) do
|
||||
preferred = ["id", "network-id", "name", "title", "email", "key"]
|
||||
preferred_columns = Enum.filter(preferred, &(&1 in columns))
|
||||
other_columns = columns -- preferred
|
||||
|
||||
preferred_columns ++ Enum.sort(other_columns)
|
||||
end
|
||||
|
||||
defp to_descriptor({tag, attrs, content}) do
|
||||
text =
|
||||
content
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.join("")
|
||||
|> String.trim()
|
||||
|
||||
%{"tag" => tag, "attrs" => Map.new(attrs), "text" => text}
|
||||
end
|
||||
|
||||
defp escape_attr(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> Plug.HTML.html_escape_to_iodata()
|
||||
|> IO.iodata_to_binary()
|
||||
end
|
||||
|
||||
defp escape_html(value) do
|
||||
value
|
||||
|> to_string()
|
||||
|> Plug.HTML.html_escape_to_iodata()
|
||||
|> IO.iodata_to_binary()
|
||||
end
|
||||
|
||||
defp pascal_case(tag) do
|
||||
tag
|
||||
|> String.trim_trailing("-")
|
||||
|> String.split("-")
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> Enum.map_join("", &String.capitalize/1)
|
||||
end
|
||||
end
|
||||
108
lib/forum/forms/watcher.ex
Normal file
108
lib/forum/forms/watcher.ex
Normal file
@@ -0,0 +1,108 @@
|
||||
defmodule Forum.Forms.Watcher do
|
||||
@moduledoc """
|
||||
Watches the canonical forms.html file and reloads `Forum.Forms` when it changes.
|
||||
|
||||
This intentionally uses a small polling loop instead of an external file watcher
|
||||
dependency so it works the same way in dev, test, and releases.
|
||||
"""
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
@poll_ms 500
|
||||
@debounce_ms 200
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
paths = paths()
|
||||
state = %{paths: paths, signature: signature(paths), debounce_ref: nil}
|
||||
|
||||
schedule_poll()
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:poll, state) do
|
||||
paths = paths()
|
||||
next_signature = signature(paths)
|
||||
state = %{state | paths: paths}
|
||||
|
||||
state =
|
||||
if next_signature != state.signature do
|
||||
schedule_reload(%{state | signature: next_signature})
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
schedule_poll()
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info({:reload, signature}, %{signature: signature} = state) do
|
||||
case reload_forms() do
|
||||
:ok ->
|
||||
Logger.info("Forum.Forms.Watcher: reloaded #{Enum.join(state.paths, ", ")}")
|
||||
broadcast_reload()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Forum.Forms.Watcher: reload failed: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
{:noreply, %{state | debounce_ref: nil}}
|
||||
end
|
||||
|
||||
def handle_info({:reload, _stale_signature}, state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp schedule_reload(%{debounce_ref: ref} = state) when is_reference(ref) do
|
||||
Process.cancel_timer(ref)
|
||||
schedule_reload(%{state | debounce_ref: nil})
|
||||
end
|
||||
|
||||
defp schedule_reload(state) do
|
||||
ref = Process.send_after(self(), {:reload, state.signature}, @debounce_ms)
|
||||
%{state | debounce_ref: ref}
|
||||
end
|
||||
|
||||
defp schedule_poll do
|
||||
Process.send_after(self(), :poll, @poll_ms)
|
||||
end
|
||||
|
||||
defp paths do
|
||||
[Forum.Forms.path() | Enum.map(Forum.Forms.network_paths(), fn {_slug, path} -> path end)]
|
||||
end
|
||||
|
||||
defp signature(paths) do
|
||||
Enum.map(paths, fn path ->
|
||||
case File.read(path) do
|
||||
{:ok, raw} -> {path, :ok, :erlang.phash2(raw), byte_size(raw)}
|
||||
{:error, reason} -> {path, :error, reason}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp reload_forms do
|
||||
try do
|
||||
Forum.Forms.reload()
|
||||
rescue
|
||||
error -> {:error, error}
|
||||
catch
|
||||
kind, reason -> {:error, {kind, reason}}
|
||||
else
|
||||
:ok -> :ok
|
||||
other -> {:error, other}
|
||||
end
|
||||
end
|
||||
|
||||
defp broadcast_reload do
|
||||
html = Forum.Forms.html()
|
||||
|
||||
for {pid, _value} <- Registry.lookup(Forum.FormsSubscriberRegistry, :forms) do
|
||||
send(pid, {:forms_reloaded, html})
|
||||
end
|
||||
end
|
||||
end
|
||||
61
lib/forum/log_store.ex
Normal file
61
lib/forum/log_store.ex
Normal file
@@ -0,0 +1,61 @@
|
||||
defmodule Forum.LogStore do
|
||||
@moduledoc """
|
||||
Persists request and hosting logs to a JSON file.
|
||||
"""
|
||||
use GenServer
|
||||
|
||||
@max_entries 1_000
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
def add(entry) when is_map(entry) do
|
||||
GenServer.cast(__MODULE__, {:add, entry})
|
||||
end
|
||||
|
||||
def list(limit \\ 200) do
|
||||
GenServer.call(__MODULE__, {:list, limit})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
path = Path.expand("priv/logs/requests.json", File.cwd!())
|
||||
File.mkdir_p!(Path.dirname(path))
|
||||
|
||||
{:ok, %{path: path, entries: read_entries(path)}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:add, entry}, state) do
|
||||
entry =
|
||||
entry
|
||||
|> Map.put_new("id", System.unique_integer([:positive, :monotonic]))
|
||||
|> Map.put_new("time", DateTime.utc_now() |> DateTime.to_iso8601())
|
||||
|
||||
entries = [entry | state.entries] |> Enum.take(@max_entries)
|
||||
write_entries!(state.path, entries)
|
||||
|
||||
{:noreply, %{state | entries: entries}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:list, limit}, _from, state) do
|
||||
{:reply, Enum.take(state.entries, limit), state}
|
||||
end
|
||||
|
||||
defp read_entries(path) do
|
||||
with {:ok, raw} <- File.read(path),
|
||||
{:ok, entries} when is_list(entries) <- Jason.decode(raw) do
|
||||
entries
|
||||
else
|
||||
_ -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp write_entries!(path, entries) do
|
||||
tmp_path = path <> ".tmp"
|
||||
File.write!(tmp_path, Jason.encode!(entries, pretty: true))
|
||||
File.rename!(tmp_path, path)
|
||||
end
|
||||
end
|
||||
44
lib/forum/modules.ex
Normal file
44
lib/forum/modules.ex
Normal file
@@ -0,0 +1,44 @@
|
||||
defmodule Forum.Modules do
|
||||
@moduledoc """
|
||||
Snapshot of every loaded Erlang/Elixir module with its OTP app and
|
||||
source file. Powers the /modules endpoint.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Returns a list of maps, one per loaded module, sorted by module name.
|
||||
|
||||
Each map has string keys (JSON-friendly):
|
||||
"module" — module name, e.g. "Elixir.Forum.Router"
|
||||
"app" — OTP app the module belongs to, e.g. ":forum", or nil
|
||||
"source" — absolute path to the source file at compile time, or nil
|
||||
"mine" — true if the module belongs to this app
|
||||
"""
|
||||
def list do
|
||||
my_modules = MapSet.new(Application.spec(:forum, :modules) || [])
|
||||
|
||||
:code.all_loaded()
|
||||
|> Enum.map(fn {mod, _beam} ->
|
||||
%{
|
||||
"module" => inspect(mod),
|
||||
"app" => format_app(Application.get_application(mod)),
|
||||
"source" => source_for(mod),
|
||||
"mine" => MapSet.member?(my_modules, mod)
|
||||
}
|
||||
end)
|
||||
|> Enum.sort_by(& &1["module"])
|
||||
end
|
||||
|
||||
defp format_app(nil), do: nil
|
||||
defp format_app(app) when is_atom(app), do: inspect(app)
|
||||
|
||||
defp source_for(mod) do
|
||||
case Keyword.get(mod.module_info(:compile), :source) do
|
||||
nil -> nil
|
||||
source when is_list(source) -> List.to_string(source)
|
||||
source when is_binary(source) -> source
|
||||
_ -> nil
|
||||
end
|
||||
rescue
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
161
lib/forum/network.ex
Normal file
161
lib/forum/network.ex
Normal file
@@ -0,0 +1,161 @@
|
||||
defmodule Forum.Network do
|
||||
@moduledoc """
|
||||
A per-network process for serving the app's forms view.
|
||||
|
||||
Requests to `/` use the root forms. Requests to `/<network>` use the
|
||||
matching network forms from `priv/networks/<network>/index.html`.
|
||||
"""
|
||||
use GenServer
|
||||
|
||||
@default_hosts MapSet.new(["localhost", "127.0.0.1", "0.0.0.0", ""])
|
||||
|
||||
def child_spec(opts) do
|
||||
slug = opts |> Keyword.fetch!(:slug) |> normalize_slug()
|
||||
|
||||
%{
|
||||
id: {:network, slug},
|
||||
start: {__MODULE__, :start_link, [[slug: slug]]},
|
||||
restart: :permanent,
|
||||
shutdown: 5_000,
|
||||
type: :worker
|
||||
}
|
||||
end
|
||||
|
||||
def start_link(opts) do
|
||||
slug = Keyword.fetch!(opts, :slug)
|
||||
GenServer.start_link(__MODULE__, slug, name: via(slug))
|
||||
end
|
||||
|
||||
def normalize_slug(slug) when is_binary(slug) do
|
||||
slug
|
||||
|> String.downcase()
|
||||
|> String.split(":", parts: 2)
|
||||
|> hd()
|
||||
end
|
||||
|
||||
def serve(pid, request) do
|
||||
GenServer.call(pid, {:serve, request})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(slug) do
|
||||
{:ok, %{slug: normalize_slug(slug), root: site_root(slug), started_at: DateTime.utc_now()}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:serve, request}, _from, state) do
|
||||
response = do_serve(state, request)
|
||||
{:reply, response, state}
|
||||
end
|
||||
|
||||
defp do_serve(%{slug: slug} = state, request) do
|
||||
path = Map.fetch!(request, :path)
|
||||
|
||||
cond do
|
||||
default_site?(slug) and path in ["", "/"] ->
|
||||
desktop_response()
|
||||
|
||||
network_site?(slug) and path in ["", "/"] ->
|
||||
network_forms_response(slug)
|
||||
|
||||
File.dir?(state.root) ->
|
||||
static_response(state.root, path)
|
||||
|
||||
true ->
|
||||
%{
|
||||
status: 404,
|
||||
content_type: "text/plain",
|
||||
body:
|
||||
"No network configured for #{slug}. Create files under #{Path.relative_to_cwd(state.root)}."
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp desktop_response do
|
||||
body =
|
||||
Forum.Assets.path("ui/desktop.html")
|
||||
|> File.read!()
|
||||
|> String.replace("<!-- FORMS_HTML -->", Forum.Forms.html())
|
||||
|
||||
%{status: 200, content_type: "text/html", body: body}
|
||||
end
|
||||
|
||||
defp network_forms_response(slug) do
|
||||
body =
|
||||
Forum.Assets.path("ui/desktop.html")
|
||||
|> File.read!()
|
||||
|> String.replace("<!-- FORMS_HTML -->", Forum.Forms.html(slug))
|
||||
|
||||
%{status: 200, content_type: "text/html", body: body}
|
||||
end
|
||||
|
||||
defp static_response(root, path) do
|
||||
file_path = resolve_path(root, path)
|
||||
|
||||
cond do
|
||||
file_path == :invalid ->
|
||||
%{status: 403, content_type: "text/plain", body: "Forbidden"}
|
||||
|
||||
File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) ->
|
||||
send_file(Path.join(file_path, "index.html"))
|
||||
|
||||
File.regular?(file_path) ->
|
||||
send_file(file_path)
|
||||
|
||||
true ->
|
||||
%{status: 404, content_type: "text/plain", body: "Not found"}
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_path(root, path) do
|
||||
relative =
|
||||
path
|
||||
|> String.trim_leading("/")
|
||||
|> URI.decode()
|
||||
|
||||
candidate = Path.expand(relative, root)
|
||||
rel_to_root = Path.relative_to(candidate, Path.expand(root))
|
||||
|
||||
if rel_to_root == ".." or String.starts_with?(rel_to_root, "../") do
|
||||
:invalid
|
||||
else
|
||||
candidate
|
||||
end
|
||||
end
|
||||
|
||||
defp send_file(path) do
|
||||
%{status: 200, content_type: content_type(path), body: File.read!(path)}
|
||||
end
|
||||
|
||||
defp content_type(path) do
|
||||
case Path.extname(path) do
|
||||
".html" -> "text/html"
|
||||
".css" -> "text/css"
|
||||
".js" -> "application/javascript"
|
||||
".json" -> "application/json"
|
||||
".svg" -> "image/svg+xml"
|
||||
".png" -> "image/png"
|
||||
".jpg" -> "image/jpeg"
|
||||
".jpeg" -> "image/jpeg"
|
||||
".webp" -> "image/webp"
|
||||
".gif" -> "image/gif"
|
||||
".ico" -> "image/x-icon"
|
||||
".txt" -> "text/plain"
|
||||
_ -> "application/octet-stream"
|
||||
end
|
||||
end
|
||||
|
||||
defp site_root(slug) do
|
||||
source_path = Path.expand(Path.join(["priv", "networks", normalize_slug(slug)]), File.cwd!())
|
||||
|
||||
if File.exists?(source_path) do
|
||||
source_path
|
||||
else
|
||||
Application.app_dir(:forum, Path.join(["priv", "networks", normalize_slug(slug)]))
|
||||
end
|
||||
end
|
||||
|
||||
defp default_site?(slug), do: MapSet.member?(@default_hosts, slug)
|
||||
defp network_site?(slug), do: slug in Forum.Networks.network_slugs()
|
||||
defp via(slug), do: {:via, Registry, {Forum.ProcessRegistry, {:network, normalize_slug(slug)}}}
|
||||
end
|
||||
56
lib/forum/network_supervisor.ex
Normal file
56
lib/forum/network_supervisor.ex
Normal file
@@ -0,0 +1,56 @@
|
||||
defmodule Forum.Networks do
|
||||
@moduledoc """
|
||||
Starts one network process for each forms network the app serves.
|
||||
"""
|
||||
use DynamicSupervisor
|
||||
|
||||
def start_link(opts) do
|
||||
DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
DynamicSupervisor.init(strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def get_or_start(slug) when is_binary(slug) do
|
||||
key = Forum.Network.normalize_slug(slug)
|
||||
|
||||
case Registry.lookup(Forum.ProcessRegistry, {:network, key}) do
|
||||
[{pid, _}] ->
|
||||
{:ok, pid}
|
||||
|
||||
[] ->
|
||||
spec = {Forum.Network, slug: key}
|
||||
|
||||
case DynamicSupervisor.start_child(__MODULE__, spec) do
|
||||
{:ok, pid} -> {:ok, pid}
|
||||
{:error, {:already_started, pid}} -> {:ok, pid}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def networks do
|
||||
Forum.ProcessRegistry
|
||||
|> Registry.select([
|
||||
{{{:network, :"$1"}, :"$2", :_}, [], [{{:"$1", :"$2"}}]}
|
||||
])
|
||||
end
|
||||
|
||||
def start_networks do
|
||||
for slug <- network_slugs() do
|
||||
get_or_start(slug)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
def network_slugs do
|
||||
"networks/*"
|
||||
|> Forum.Assets.paths()
|
||||
|> Enum.filter(&File.dir?/1)
|
||||
|> Enum.map(&Path.basename/1)
|
||||
|> Enum.sort()
|
||||
end
|
||||
end
|
||||
175
lib/forum/processes.ex
Normal file
175
lib/forum/processes.ex
Normal file
@@ -0,0 +1,175 @@
|
||||
defmodule Forum.Processes do
|
||||
@moduledoc """
|
||||
Snapshot of every BEAM process for the /processes endpoint. Pure data
|
||||
function — the router serves a shell HTML and the WS handler returns
|
||||
the rows produced here.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Returns a list of maps, one per live process, sorted by memory desc.
|
||||
|
||||
Each map has string keys (JSON-friendly):
|
||||
"pid" — inspected PID string, e.g. "#PID<0.123.0>"
|
||||
"parent" — inspected PID string of immediate parent (from
|
||||
$ancestors), or nil for roots / non-proc_lib processes
|
||||
"name" — registered name string, or nil
|
||||
"initial_call" — real $initial_call MFA, formatted as "Mod.fun/arity"
|
||||
"memory_kb" — process memory in KB
|
||||
"tree_memory_kb" — process memory plus all descendant process memory in KB
|
||||
"msgs" — message queue length
|
||||
"status" — :running | :runnable | :waiting | :suspended | :exiting
|
||||
"mine" — true if the process is under Forum.Supervisor's tree
|
||||
"""
|
||||
# Registries to consult for "via" names — processes registered as
|
||||
# {:via, Registry, {Foo, key}} don't show up under :registered_name,
|
||||
# so we look them up by pid here and use the key as the display name.
|
||||
@registries [
|
||||
Forum.ProcessRegistry,
|
||||
Forum.FormsSubscriberRegistry
|
||||
]
|
||||
|
||||
def list do
|
||||
my_pids = my_pids()
|
||||
|
||||
Process.list()
|
||||
|> Enum.map(&info_for(&1, my_pids))
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> add_tree_memory()
|
||||
|> Enum.sort_by(& &1["memory_kb"], :desc)
|
||||
end
|
||||
|
||||
defp add_tree_memory(rows) do
|
||||
by_parent = Enum.group_by(rows, & &1["parent"])
|
||||
{totals, _memo} =
|
||||
Enum.map_reduce(rows, %{}, fn row, memo ->
|
||||
{total, memo} = tree_memory(row, by_parent, memo)
|
||||
{{row["pid"], total}, memo}
|
||||
end)
|
||||
|
||||
totals = Map.new(totals)
|
||||
|
||||
Enum.map(rows, fn row ->
|
||||
Map.put(row, "tree_memory_kb", Map.fetch!(totals, row["pid"]))
|
||||
end)
|
||||
end
|
||||
|
||||
defp tree_memory(row, by_parent, memo) do
|
||||
pid = row["pid"]
|
||||
|
||||
case memo do
|
||||
%{^pid => total} ->
|
||||
{total, memo}
|
||||
|
||||
%{} ->
|
||||
children = Map.get(by_parent, pid, [])
|
||||
|
||||
{children_total, memo} =
|
||||
Enum.reduce(children, {0, memo}, fn child, {sum, memo} ->
|
||||
{total, memo} = tree_memory(child, by_parent, memo)
|
||||
{sum + total, memo}
|
||||
end)
|
||||
|
||||
total = row["memory_kb"] + children_total
|
||||
{total, Map.put(memo, pid, total)}
|
||||
end
|
||||
end
|
||||
|
||||
defp info_for(pid, my_pids) do
|
||||
case Process.info(pid, [
|
||||
:registered_name,
|
||||
:initial_call,
|
||||
:memory,
|
||||
:message_queue_len,
|
||||
:status,
|
||||
:dictionary
|
||||
]) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
info ->
|
||||
dict = info[:dictionary] || []
|
||||
real_call = Keyword.get(dict, :"$initial_call") || info[:initial_call]
|
||||
ancestors = Keyword.get(dict, :"$ancestors", [])
|
||||
|
||||
name =
|
||||
format_name(info[:registered_name]) || lookup_registry_name(pid)
|
||||
|
||||
%{
|
||||
"pid" => inspect(pid),
|
||||
"parent" => parent_string(ancestors),
|
||||
"name" => name,
|
||||
"initial_call" => format_mfa(real_call),
|
||||
"memory_kb" => div(info[:memory] || 0, 1024),
|
||||
"msgs" => info[:message_queue_len] || 0,
|
||||
"status" => info[:status],
|
||||
"mine" => MapSet.member?(my_pids, pid)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp lookup_registry_name(pid) do
|
||||
@registries
|
||||
|> Enum.flat_map(fn registry ->
|
||||
try do
|
||||
Registry.keys(registry, pid) |> Enum.map(&format_registry_key/1)
|
||||
rescue
|
||||
# Registry not started yet (boot race) — skip silently.
|
||||
ArgumentError -> []
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
[] -> nil
|
||||
names -> Enum.join(names, ", ")
|
||||
end
|
||||
end
|
||||
|
||||
# {"Task", "1"} -> "Task#1". Falls back to inspect for unknown shapes.
|
||||
defp format_registry_key({:form, {class, id}}) when is_binary(class), do: "#{class}##{id}"
|
||||
defp format_registry_key({:network, slug}), do: "network:#{slug}"
|
||||
defp format_registry_key({:public_site, network}), do: "public-site:#{network}"
|
||||
defp format_registry_key({:http_server, :main}), do: "http-server:main"
|
||||
defp format_registry_key({:http_server, {:network, network}}), do: "http-server:#{network}"
|
||||
defp format_registry_key(:forms), do: "forms-subscriber:forms"
|
||||
defp format_registry_key(other), do: inspect(other)
|
||||
|
||||
# The first entry in $ancestors is the immediate parent. proc_lib
|
||||
# stores PIDs directly; for processes registered by name it can also
|
||||
# be the registered name (atom), which we resolve to its current PID.
|
||||
defp parent_string([pid | _]) when is_pid(pid), do: inspect(pid)
|
||||
|
||||
defp parent_string([name | _]) when is_atom(name) do
|
||||
case Process.whereis(name) do
|
||||
nil -> nil
|
||||
pid -> inspect(pid)
|
||||
end
|
||||
end
|
||||
|
||||
defp parent_string(_), do: nil
|
||||
|
||||
defp my_pids do
|
||||
case Process.whereis(Forum.Supervisor) do
|
||||
nil -> MapSet.new()
|
||||
sup -> MapSet.new([sup | all_descendants(sup)])
|
||||
end
|
||||
end
|
||||
|
||||
defp all_descendants(sup) do
|
||||
Supervisor.which_children(sup)
|
||||
|> Enum.flat_map(fn {_id, pid, type, _modules} ->
|
||||
cond do
|
||||
not is_pid(pid) -> []
|
||||
type == :supervisor -> [pid | all_descendants(pid)]
|
||||
true -> [pid]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_name([]), do: nil
|
||||
defp format_name(name) when is_atom(name), do: inspect(name)
|
||||
defp format_name(_), do: nil
|
||||
|
||||
defp format_mfa({m, f, a}) when is_atom(m) and is_atom(f) and is_integer(a),
|
||||
do: "#{inspect(m)}.#{f}/#{a}"
|
||||
|
||||
defp format_mfa(_), do: "-"
|
||||
end
|
||||
182
lib/forum/public_site.ex
Normal file
182
lib/forum/public_site.ex
Normal file
@@ -0,0 +1,182 @@
|
||||
defmodule Forum.PublicSite do
|
||||
@moduledoc """
|
||||
A per-network process for serving public static websites on separate ports.
|
||||
"""
|
||||
use GenServer
|
||||
|
||||
def child_spec(opts) do
|
||||
network = opts |> Keyword.fetch!(:network) |> normalize_network()
|
||||
|
||||
%{
|
||||
id: {:public_site, network},
|
||||
start: {__MODULE__, :start_link, [[network: network]]},
|
||||
restart: :permanent,
|
||||
shutdown: 5_000,
|
||||
type: :worker
|
||||
}
|
||||
end
|
||||
|
||||
def start_link(opts) do
|
||||
network = Keyword.fetch!(opts, :network)
|
||||
GenServer.start_link(__MODULE__, network, name: via(network))
|
||||
end
|
||||
|
||||
def normalize_network(network) when is_binary(network) do
|
||||
network
|
||||
|> String.downcase()
|
||||
|> String.split(":", parts: 2)
|
||||
|> hd()
|
||||
end
|
||||
|
||||
def serve(pid, request) do
|
||||
GenServer.call(pid, {:serve, request})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(network) do
|
||||
{:ok, %{network: normalize_network(network), started_at: DateTime.utc_now()}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:serve, request}, _from, state) do
|
||||
response = do_serve(state, request)
|
||||
{:reply, response, state}
|
||||
end
|
||||
|
||||
defp do_serve(%{network: network}, request) do
|
||||
roots = public_roots(network)
|
||||
|
||||
case roots do
|
||||
[] ->
|
||||
%{
|
||||
status: 404,
|
||||
content_type: "text/plain",
|
||||
body: "No public website configured for #{network}"
|
||||
}
|
||||
|
||||
[primary | fallbacks] ->
|
||||
static_response(primary, request.path, fallbacks)
|
||||
end
|
||||
end
|
||||
|
||||
defp static_response(root, path, fallback_roots \\ []) do
|
||||
file_path = resolve_path(root, path)
|
||||
|
||||
cond do
|
||||
file_path == :invalid ->
|
||||
%{status: 403, content_type: "text/plain", body: "Forbidden"}
|
||||
|
||||
File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) ->
|
||||
send_file(Path.join(file_path, "index.html"))
|
||||
|
||||
File.regular?(file_path) ->
|
||||
send_file(file_path)
|
||||
|
||||
versioned = find_versioned_file([root | fallback_roots], path) ->
|
||||
send_file(versioned)
|
||||
|
||||
fallback = find_fallback_file(fallback_roots, path) ->
|
||||
send_file(fallback)
|
||||
|
||||
true ->
|
||||
%{status: 404, content_type: "text/plain", body: "Not found"}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_versioned_file(roots, path) do
|
||||
case path |> String.trim_leading("/") |> Path.split() do
|
||||
[_version | rest] when rest != [] ->
|
||||
stripped_path = "/" <> Path.join(rest)
|
||||
find_fallback_file(roots, stripped_path)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp find_fallback_file(roots, path) do
|
||||
Enum.find_value(roots, fn root ->
|
||||
file_path = resolve_path(root, path)
|
||||
|
||||
cond do
|
||||
file_path == :invalid ->
|
||||
nil
|
||||
|
||||
File.dir?(file_path) and File.regular?(Path.join(file_path, "index.html")) ->
|
||||
Path.join(file_path, "index.html")
|
||||
|
||||
File.regular?(file_path) ->
|
||||
file_path
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp resolve_path(root, path) do
|
||||
relative =
|
||||
path
|
||||
|> String.trim_leading("/")
|
||||
|> URI.decode()
|
||||
|
||||
candidate = Path.expand(relative, root)
|
||||
rel_to_root = Path.relative_to(candidate, Path.expand(root))
|
||||
|
||||
if rel_to_root == ".." or String.starts_with?(rel_to_root, "../") do
|
||||
:invalid
|
||||
else
|
||||
candidate
|
||||
end
|
||||
end
|
||||
|
||||
defp send_file(path) do
|
||||
%{status: 200, content_type: content_type(path), body: File.read!(path)}
|
||||
end
|
||||
|
||||
defp content_type(path) do
|
||||
case Path.extname(path) do
|
||||
".html" -> "text/html"
|
||||
".css" -> "text/css"
|
||||
".js" -> "application/javascript"
|
||||
".json" -> "application/json"
|
||||
".svg" -> "image/svg+xml"
|
||||
".png" -> "image/png"
|
||||
".jpg" -> "image/jpeg"
|
||||
".jpeg" -> "image/jpeg"
|
||||
".webp" -> "image/webp"
|
||||
".gif" -> "image/gif"
|
||||
".ico" -> "image/x-icon"
|
||||
".txt" -> "text/plain"
|
||||
_ -> "application/octet-stream"
|
||||
end
|
||||
end
|
||||
|
||||
defp public_roots("cs") do
|
||||
["networks/cs/website"]
|
||||
|> Enum.map(&Forum.Assets.path/1)
|
||||
|> Enum.filter(&File.dir?/1)
|
||||
end
|
||||
|
||||
defp public_roots("comalyr") do
|
||||
[
|
||||
"networks/comalyr/comalyr.com/ui/desktop",
|
||||
"networks/comalyr/comalyr.com/ui"
|
||||
]
|
||||
|> Enum.map(&Forum.Assets.path/1)
|
||||
|> Enum.filter(&File.dir?/1)
|
||||
end
|
||||
|
||||
defp public_roots(network) do
|
||||
[
|
||||
Path.join(["networks", network, "website"]),
|
||||
Path.join(["networks", network, "public"]),
|
||||
Path.join(["networks", network, "www"])
|
||||
]
|
||||
|> Enum.map(&Forum.Assets.path/1)
|
||||
|> Enum.filter(&File.dir?/1)
|
||||
end
|
||||
|
||||
defp via(network),
|
||||
do: {:via, Registry, {Forum.ProcessRegistry, {:public_site, normalize_network(network)}}}
|
||||
end
|
||||
60
lib/forum/public_site_router.ex
Normal file
60
lib/forum/public_site_router.ex
Normal file
@@ -0,0 +1,60 @@
|
||||
defmodule Forum.PublicSiteRouter do
|
||||
@moduledoc """
|
||||
Serves one network's public website on its own port.
|
||||
"""
|
||||
import Plug.Conn
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, opts) do
|
||||
network = Keyword.fetch!(opts, :network)
|
||||
started_at = System.monotonic_time(:microsecond)
|
||||
|
||||
response =
|
||||
with {:ok, pid} <- Forum.PublicSiteSupervisor.get_or_start(network) do
|
||||
Forum.PublicSite.serve(pid, %{
|
||||
method: conn.method,
|
||||
path: conn.request_path,
|
||||
query_string: conn.query_string,
|
||||
headers: conn.req_headers
|
||||
})
|
||||
else
|
||||
{:error, reason} ->
|
||||
%{
|
||||
status: 500,
|
||||
content_type: "text/plain",
|
||||
body: "Unable to start public site: #{inspect(reason)}"
|
||||
}
|
||||
end
|
||||
|
||||
duration_us = System.monotonic_time(:microsecond) - started_at
|
||||
|
||||
Forum.LogStore.add(%{
|
||||
"source_ip" => remote_ip(conn),
|
||||
"host" => conn.host || "",
|
||||
"network" => network,
|
||||
"method" => conn.method,
|
||||
"path" => conn.request_path,
|
||||
"query_string" => conn.query_string,
|
||||
"status" => response.status,
|
||||
"duration_ms" => Float.round(duration_us / 1_000, 2)
|
||||
})
|
||||
|
||||
conn
|
||||
|> put_resp_content_type(response.content_type)
|
||||
|> send_resp(response.status, response.body)
|
||||
end
|
||||
|
||||
defp remote_ip(conn) do
|
||||
case Plug.Conn.get_req_header(conn, "x-real-ip") do
|
||||
[ip | _] -> ip
|
||||
[] -> fallback_ip(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp fallback_ip(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do
|
||||
remote_ip |> :inet.ntoa() |> to_string()
|
||||
end
|
||||
|
||||
defp fallback_ip(_), do: ""
|
||||
end
|
||||
48
lib/forum/public_site_supervisor.ex
Normal file
48
lib/forum/public_site_supervisor.ex
Normal file
@@ -0,0 +1,48 @@
|
||||
defmodule Forum.PublicSiteSupervisor do
|
||||
@moduledoc """
|
||||
Starts one public-site process for each network with a separate port.
|
||||
"""
|
||||
use DynamicSupervisor
|
||||
|
||||
def start_link(opts) do
|
||||
DynamicSupervisor.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
DynamicSupervisor.init(strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def get_or_start(network) when is_binary(network) do
|
||||
key = Forum.PublicSite.normalize_network(network)
|
||||
|
||||
case Registry.lookup(Forum.ProcessRegistry, {:public_site, key}) do
|
||||
[{pid, _}] ->
|
||||
{:ok, pid}
|
||||
|
||||
[] ->
|
||||
spec = {Forum.PublicSite, network: key}
|
||||
|
||||
case DynamicSupervisor.start_child(__MODULE__, spec) do
|
||||
{:ok, pid} -> {:ok, pid}
|
||||
{:error, {:already_started, pid}} -> {:ok, pid}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def public_sites do
|
||||
Forum.ProcessRegistry
|
||||
|> Registry.select([
|
||||
{{{:public_site, :"$1"}, :"$2", :_}, [], [{{:"$1", :"$2"}}]}
|
||||
])
|
||||
end
|
||||
|
||||
def start_networks do
|
||||
for slug <- Forum.Networks.network_slugs() do
|
||||
get_or_start(slug)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
125
lib/forum/router.ex
Normal file
125
lib/forum/router.ex
Normal file
@@ -0,0 +1,125 @@
|
||||
defmodule Forum.Router do
|
||||
use Plug.Router
|
||||
use Joken.Config
|
||||
require Logger
|
||||
|
||||
plug Plug.Logger
|
||||
plug :log_request
|
||||
plug :fetch_cookies
|
||||
plug :verify_session_host
|
||||
plug :match
|
||||
plug :dispatch
|
||||
|
||||
defp verify_session_host(conn, _opts) do
|
||||
if conn.host == "session.frm.so" do
|
||||
case authenticate(conn) do
|
||||
{:ok, claims} ->
|
||||
conn
|
||||
|> Plug.Conn.assign(:claims, claims)
|
||||
|
||||
:error ->
|
||||
conn |> send_resp(401, "unauthorized") |> halt()
|
||||
end
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
defp authenticate(conn) do
|
||||
with token when is_binary(token) <- conn.cookies["auth_token"], {:ok, claims} <- verify_token(token) do
|
||||
{:ok, claims}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_token(token) do
|
||||
secret = Application.fetch_env!(:forum, :auth)[:jwt_secret]
|
||||
signer = Joken.Signer.create("HS256", secret)
|
||||
|
||||
case Joken.verify_and_validate(%{}, token, signer) do
|
||||
{:ok, claims} -> {:ok, claims}
|
||||
{:error, _reason} = err -> err
|
||||
end
|
||||
end
|
||||
|
||||
get "/admin", host: "session.frm.so", do: handle_admin(conn)
|
||||
get "/admin/graphyellow.svg", host: "session.frm.so", do: admin_asset(conn, "ui/graphyellow.svg", "image/svg+xml")
|
||||
get "/admin/*path", host: "session.frm.so", do: handle_admin(conn)
|
||||
|
||||
get "/", host: "session.frm.so" do
|
||||
user_id = conn.assigns.claims["id"]
|
||||
email = conn.assigns.claims["email"]
|
||||
conn
|
||||
|> WebSockAdapter.upgrade(Forum.WsHandler, %{user_id: user_id, email: email}, timeout: :infinity)
|
||||
|> halt()
|
||||
end
|
||||
|
||||
match _ do
|
||||
send_resp(conn, 404, "not found")
|
||||
end
|
||||
|
||||
defp handle_admin(conn) do
|
||||
if websocket_upgrade?(conn) do
|
||||
conn
|
||||
|> WebSockAdapter.upgrade(Forum.WsHandler, %{}, timeout: :infinity)
|
||||
|> halt()
|
||||
else
|
||||
admin_page(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp websocket_upgrade?(conn) do
|
||||
connection = get_req_header(conn, "connection") |> Enum.join(",") |> String.downcase()
|
||||
upgrade = get_req_header(conn, "upgrade") |> List.first() |> to_string() |> String.downcase()
|
||||
|
||||
String.contains?(connection, "upgrade") and upgrade == "websocket"
|
||||
end
|
||||
|
||||
defp admin_page(conn) do
|
||||
html = File.read!(Forum.Assets.path("ui/admin.html"))
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/html")
|
||||
|> send_resp(200, html)
|
||||
end
|
||||
|
||||
defp admin_asset(conn, path, content_type) do
|
||||
conn
|
||||
|> put_resp_content_type(content_type)
|
||||
|> send_file(200, Forum.Assets.path(path))
|
||||
end
|
||||
|
||||
defp log_request(conn, _opts) do
|
||||
started_at = System.monotonic_time(:microsecond)
|
||||
|
||||
Plug.Conn.register_before_send(conn, fn conn ->
|
||||
duration_us = System.monotonic_time(:microsecond) - started_at
|
||||
|
||||
Forum.LogStore.add(%{
|
||||
"source_ip" => remote_ip(conn),
|
||||
"host" => conn.host || "",
|
||||
"method" => conn.method,
|
||||
"path" => conn.request_path,
|
||||
"query_string" => conn.query_string,
|
||||
"status" => conn.status || 0,
|
||||
"duration_ms" => Float.round(duration_us / 1_000, 2)
|
||||
})
|
||||
|
||||
conn
|
||||
end)
|
||||
end
|
||||
|
||||
defp remote_ip(conn) do
|
||||
case Plug.Conn.get_req_header(conn, "x-real-ip") do
|
||||
[ip | _] -> ip
|
||||
[] -> fallback_ip(conn)
|
||||
end
|
||||
end
|
||||
|
||||
defp fallback_ip(%{remote_ip: remote_ip}) when is_tuple(remote_ip) do
|
||||
remote_ip |> :inet.ntoa() |> to_string()
|
||||
end
|
||||
|
||||
defp fallback_ip(_), do: ""
|
||||
end
|
||||
24
lib/forum/vm_memory.ex
Normal file
24
lib/forum/vm_memory.ex
Normal file
@@ -0,0 +1,24 @@
|
||||
defmodule Forum.VmMemory do
|
||||
@moduledoc """
|
||||
Snapshot of BEAM VM memory categories.
|
||||
|
||||
These numbers come from `:erlang.memory/0`, so they describe memory held by
|
||||
the whole VM rather than memory attributed to a particular Erlang process.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Returns VM memory categories as JSON-friendly maps.
|
||||
"""
|
||||
def list do
|
||||
:erlang.memory()
|
||||
|> Enum.map(fn {category, bytes} ->
|
||||
%{
|
||||
"category" => Atom.to_string(category),
|
||||
"bytes" => bytes,
|
||||
"kb" => div(bytes, 1024),
|
||||
"mb" => Float.round(bytes / 1_048_576, 2)
|
||||
}
|
||||
end)
|
||||
|> Enum.sort_by(& &1["bytes"], :desc)
|
||||
end
|
||||
end
|
||||
110
lib/forum/ws_handler.ex
Normal file
110
lib/forum/ws_handler.ex
Normal file
@@ -0,0 +1,110 @@
|
||||
defmodule Forum.WsHandler do
|
||||
@moduledoc """
|
||||
One instance of this module runs per connected WebSocket client.
|
||||
Each instance is its own BEAM process — isolated state, isolated crashes.
|
||||
|
||||
Client messages arrive in `handle_in/2`. Reply with `{:push, frame, state}`,
|
||||
do nothing with `{:ok, state}`, or close with `{:stop, reason, state}`.
|
||||
"""
|
||||
@behaviour WebSock
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
Registry.register(Forum.FormsSubscriberRegistry, :forms, nil)
|
||||
Logger.info("ws connected: user_id=#{opts[:user_id]}")
|
||||
|
||||
state = %{
|
||||
message_count: 0,
|
||||
user_id: opts[:user_id],
|
||||
email: opts[:email]
|
||||
}
|
||||
|
||||
welcome = Jason.encode!(%{
|
||||
type: "welcome to this session: ",
|
||||
user_id: opts[:user_id]
|
||||
})
|
||||
|
||||
schedule_heartbeat()
|
||||
|
||||
{:push, {:text, welcome}, state}
|
||||
end
|
||||
|
||||
# Text frame from client.
|
||||
# Supported commands (sent as JSON over the wire):
|
||||
# {"type": "ping"} -> {"type": "pong"}
|
||||
# {"type": "send", "text": "hi"} -> inserts row, returns the row
|
||||
# {"type": "list"} -> returns latest 50 rows
|
||||
# {"type": "get_doc"} -> returns the backend body HTML
|
||||
# {"type": "reload_forms"} -> re-reads forms.html and reconciles runtimes
|
||||
# {"type": "list_processes"} -> returns a snapshot of all live processes
|
||||
# {"type": "list_logs"} -> returns recent request logs
|
||||
# {"type": "list_vm_memory"} -> returns BEAM VM memory categories
|
||||
# Anything else echoes back.
|
||||
@impl true
|
||||
def handle_in({raw, [opcode: :text]}, state) do
|
||||
state = %{state | message_count: state.message_count + 1}
|
||||
|
||||
response =
|
||||
case Jason.decode(raw) do
|
||||
{:ok, %{"type" => "ping"}} ->
|
||||
%{type: "pong"}
|
||||
|
||||
{:ok, %{"type" => "send", "text" => text}} when is_binary(text) and text != "" ->
|
||||
row = Forum.DB.insert_message(text)
|
||||
%{type: "inserted", row: row}
|
||||
|
||||
{:ok, %{"type" => "list"}} ->
|
||||
%{type: "list", rows: Forum.DB.list_messages(limit: 50)}
|
||||
|
||||
{:ok, %{"type" => "get_doc"}} ->
|
||||
%{type: "doc", html: Forum.Forms.html()}
|
||||
|
||||
{:ok, %{"type" => "reload_forms"}} ->
|
||||
:ok = Forum.Forms.reload()
|
||||
%{type: "doc", html: Forum.Forms.html()}
|
||||
|
||||
{:ok, %{"type" => "list_processes"}} ->
|
||||
%{type: "processes", rows: Forum.Processes.list()}
|
||||
|
||||
{:ok, %{"type" => "list_modules"}} ->
|
||||
%{type: "modules", rows: Forum.Modules.list()}
|
||||
|
||||
{:ok, %{"type" => "list_logs"}} ->
|
||||
%{type: "logs", rows: Forum.LogStore.list()}
|
||||
|
||||
{:ok, %{"type" => "list_vm_memory"}} ->
|
||||
%{type: "vm_memory", rows: Forum.VmMemory.list()}
|
||||
|
||||
_ ->
|
||||
%{type: "echo", raw: raw, count: state.message_count}
|
||||
end
|
||||
|
||||
{:push, {:text, Jason.encode!(response)}, state}
|
||||
end
|
||||
|
||||
# Binary frame from client — ignore in this scaffold.
|
||||
def handle_in({_data, [opcode: :binary]}, state), do: {:ok, state}
|
||||
|
||||
# Messages sent to this process's mailbox from other processes.
|
||||
@impl true
|
||||
def handle_info({:forms_reloaded, html}, state) do
|
||||
{:push, {:text, Jason.encode!(%{type: "doc", html: html})}, state}
|
||||
end
|
||||
|
||||
def handle_info(_msg, state), do: {:ok, state}
|
||||
|
||||
def handle_info(:heartbeat, state) do
|
||||
schedule_heartbeat()
|
||||
{:push, {:ping, ""}, state}
|
||||
end
|
||||
|
||||
defp schedule_heartbeat, do: Process.send_after(self(), :heartbeat, 30_000)
|
||||
|
||||
@impl true
|
||||
def terminate(reason, _state) do
|
||||
Logger.info("ws disconnected: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user