berrypod/lib/berrypod_web/controllers/newsletter_controller.ex
jamey 0c2d4ac406
Some checks failed
deploy / deploy (push) Failing after 8m33s
add rate limiting and HSTS for security hardening
- Add Hammer library for rate limiting with ETS backend
- Rate limit login (5/min), magic link (3/min), newsletter (10/min), API (60/min)
- Add themed 429 error page using bare shop styling
- Enable HSTS in production with rewrite_on for Fly proxy
- Add security hardening plan to docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-08 08:58:43 +00:00

111 lines
3.3 KiB
Elixir

defmodule BerrypodWeb.NewsletterController do
use BerrypodWeb, :controller
alias Berrypod.Newsletter
plug BerrypodWeb.Plugs.RateLimit, [type: :newsletter] when action == :subscribe
@doc "No-JS fallback for newsletter signup form."
def subscribe(conn, %{"email" => email}) do
ip_hash = hash_ip(conn)
case Newsletter.subscribe(email,
consent_text: "Newsletter signup on website",
ip_hash: ip_hash
) do
{:ok, _} ->
conn
|> put_flash(:info, "Check your inbox to confirm your subscription.")
|> redirect(to: redirect_back(conn))
{:already_confirmed, _} ->
conn
|> put_flash(:info, "You're already subscribed!")
|> redirect(to: redirect_back(conn))
{:error, _} ->
conn
|> put_flash(:error, "Please enter a valid email address.")
|> redirect(to: redirect_back(conn))
end
end
def subscribe(conn, _params) do
conn
|> put_flash(:error, "Please enter your email address.")
|> redirect(to: ~p"/")
end
@doc "Handles confirmation link from the double opt-in email."
def confirm(conn, %{"token" => token}) do
case Newsletter.confirm(token) do
{:ok, _sub} ->
conn
|> put_status(200)
|> html(confirmation_html(:success))
{:error, :invalid_token} ->
conn
|> put_status(400)
|> html(confirmation_html(:invalid))
end
end
defp confirmation_html(:success) do
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Subscription confirmed</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
</head>
<body>
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You're subscribed!</h1>
<p style="color:#555">Thanks for confirming. You'll receive the #{shop_name} newsletter from now on.</p>
<p style="margin-top:1.5rem"><a href="/" style="color:#111">Back to the shop</a></p>
</body>
</html>
"""
end
defp confirmation_html(:invalid) do
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invalid link</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
</head>
<body>
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">Link invalid or expired</h1>
<p style="color:#555">This confirmation link has expired or is invalid. Please try subscribing again.</p>
<p style="margin-top:1.5rem"><a href="/" style="color:#111">Back to the shop</a></p>
</body>
</html>
"""
end
defp redirect_back(conn) do
case get_req_header(conn, "referer") do
[referer | _] ->
uri = URI.parse(referer)
uri.path || "/"
_ ->
"/"
end
end
defp hash_ip(conn) do
daily_salt = Date.utc_today() |> Date.to_iso8601()
ip_string = conn.remote_ip |> :inet.ntoa() |> to_string()
:crypto.hash(:sha256, ip_string <> daily_salt) |> Base.encode16(case: :lower)
end
end