- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia) - Jason.decode! → Jason.decode with error handling for untrusted input - inspect() leak: replace with generic error messages, log details server-side - SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes - SSRF validation added to HTTP monitoring executor and integration credentials - GraphQL complexity limits: always applied, not just in prod - GraphQL introspection: also check GET query params, not just body - Stripe webhook: explicit nil/empty checks for signature and body - Cookie security: secure flag for session (prod), http_only+secure for remember_me - Honeybadger API key: read from env var with fallback - String.to_integer → Integer.parse with fallback for URL params - String.to_atom → whitelist map for HTTP methods - Gaiia webhook: remove secret_len and expected signature from log - Admin API: add rate limiting pipeline - to_atom_keys: per-key fallback instead of all-or-nothing rescue
7.2 KiB
7.2 KiB
LiveView Form Page Template
Copy-pasteable template for a new create/edit form page following TowerOps conventions.
Replace all __RESOURCE__ with PascalCase resource (e.g., Schedule).
Replace __resource__ with snake_case (e.g., schedule).
Replace __resources__ with plural snake_case (e.g., schedules).
Replace __CONTEXT__ with context module name (e.g., Schedules).
Replace __SCHEMA__ with schema module name (e.g., Schedule).
lib/towerops_web/live/__resource___live/form.ex
defmodule ToweropsWeb.__RESOURCE__Live.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.__CONTEXT__
alias Towerops.__CONTEXT__.__SCHEMA__
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:timezone, socket.assigns.current_scope.timezone)}
end
@impl true
def handle_params(params, _url, socket) do
case socket.assigns.live_action do
:new ->
__resource__ = %__SCHEMA__{}
changeset = __SCHEMA__.changeset(__resource__, %{})
{:noreply,
socket
|> assign(:page_title, t("New __RESOURCE_TITLE__"))
|> assign(:__resource__, __resource__)
|> assign(:form, to_form(changeset))}
:edit ->
__resource__ = __CONTEXT__.get___resource__!(params["id"])
changeset = __SCHEMA__.changeset(__resource__, %{})
{:noreply,
socket
|> assign(:page_title, t("Edit __RESOURCE_TITLE__"))
|> assign(:__resource__, __resource__)
|> assign(:form, to_form(changeset))}
end
end
@impl true
def handle_event("validate", %{"__resource__" => params}, socket) do
changeset =
socket.assigns.__resource__
|> __SCHEMA__.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
def handle_event("save", %{"__resource__" => params}, socket) do
scope = socket.assigns.current_scope
params =
params
|> Map.put("organization_id", scope.organization.id)
case socket.assigns.live_action do
:new -> save___resource__(socket, :create, params)
:edit -> save___resource__(socket, :update, params)
end
end
def handle_event("delete", _params, socket) do
case __CONTEXT__.delete___resource__(socket.assigns.__resource__) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("__RESOURCE_TITLE__ deleted"))
|> push_navigate(to: ~p"/__resources__")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t("Unable to delete __resource__"))}
end
end
defp save___resource__(socket, :create, params) do
case __CONTEXT__.create___resource__(params) do
{:ok, __resource__} ->
{:noreply,
socket
|> put_flash(:info, t("__RESOURCE_TITLE__ created"))
|> push_navigate(to: ~p"/__resources__/#{__resource__.id}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp save___resource__(socket, :update, params) do
case __CONTEXT__.update___resource__(socket.assigns.__resource__, params) do
{:ok, __resource__} ->
{:noreply,
socket
|> put_flash(:info, t("__RESOURCE_TITLE__ updated"))
|> push_navigate(to: ~p"/__resources__/#{__resource__.id}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
end
lib/towerops_web/live/__resource___live/form.html.heex
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="__active_page__"
>
<%!-- Back link / breadcrumb --%>
<div class="mb-6">
<.link
navigate={~p"/__resources__"}
class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<.icon name="hero-arrow-left" class="h-4 w-4" />
{t("Back to __RESOURCES_TITLE__")}
</.link>
</div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white mb-6">{@page_title}</h1>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-6">
<.form
for={@form}
phx-change="validate"
phx-submit="save"
id="__resource__-form"
>
<div class="space-y-6">
<%!-- Name field --%>
<.input
field={@form[:name]}
type="text"
label={t("Name") <> " *"}
required
placeholder={t("e.g., My __RESOURCE_TITLE__")}
/>
<%!-- Description / reason field --%>
<.input
field={@form[:description]}
type="textarea"
label={t("Description")}
rows="3"
placeholder={t("Optional description")}
/>
<%!-- Grid layout for related fields --%>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<.input
field={@form[:starts_at]}
type="datetime-local"
label={t("Starts At") <> " *"}
required
/>
<.input
field={@form[:ends_at]}
type="datetime-local"
label={t("Ends At") <> " *"}
required
/>
</div>
<%!-- Select field example --%>
<%#
<.input
field={@form[:site_id]}
type="select"
label={t("Site")}
prompt={t("Select a site...")}
options={Enum.map(@sites, &{&1.name, &1.id})}
/>
#%>
<%!-- Checkbox example --%>
<.input
field={@form[:enabled]}
type="checkbox"
label={t("Enabled")}
/>
<%!-- Submit / Cancel / Delete --%>
<div class="flex items-center justify-between gap-3 pt-4 border-t border-gray-200 dark:border-white/10">
<%!-- Delete button (edit mode only) --%>
<div>
<%= if @live_action == :edit do %>
<button
type="button"
phx-click="delete"
data-confirm={t("Are you sure you want to delete this? This cannot be undone.")}
class="rounded-lg px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20 transition-colors"
>
{t("Delete")}
</button>
<% end %>
</div>
<div class="flex items-center gap-3">
<.link
navigate={~p"/__resources__"}
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
>
{t("Cancel")}
</.link>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 transition-colors"
>
{t("Save")}
</button>
</div>
</div>
</div>
</.form>
</div>
</Layouts.authenticated>
Router entries
# In lib/towerops_web/router.ex, inside the authenticated scope:
live "/__resources__/new", __RESOURCE__Live.Form, :new
live "/__resources__/:id/edit", __RESOURCE__Live.Form, :edit