This commit is contained in:
Sam
2026-06-10 11:51:56 -05:00
commit 66ba338b81
57 changed files with 5509 additions and 0 deletions

126
lib/forum/admin.ex Normal file
View 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("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&#39;")
end
defp h(other), do: other |> to_string() |> h()
end

72
lib/forum/application.ex Normal file
View 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
View 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
View 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
View 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>&lt;#{escape_html(tag_name)}-&gt;</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
View 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
View 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
View 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
View 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

View 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
View 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
View 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

View 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

View 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
View 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
View 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
View 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