feat(lite): expand no-JS auth surface and fix Nerve session persistence
All checks were successful
Deploy Docker Images / Build, push, and deploy (push) Successful in 17m34s

Add lite login/register with embedded captcha, account settings, prefer
lite/full cookies, and hardened captcha flows. Persist the Nerve
extension API token in local storage so browser restarts do not drop
auth, and save website entries via LiveView pushEvent.
This commit is contained in:
maxfield 2026-08-04 03:56:31 -04:00
parent 0fd86bab7e
commit b5a5b29fb0
42 changed files with 2492 additions and 724 deletions

View file

@ -137,7 +137,6 @@ export const Nerve = {
this.setError("")
if (!vaultSession.isUnlocked()) return this.setError("Enter your account password first.")
const form = this.el.querySelector("#nerve-entry-form")
const metadata = {
title: this.fieldValue('[name="entry[title]"]'),
login_username: this.fieldValue('[name="entry[login_username]"]'),
@ -146,30 +145,39 @@ export const Nerve = {
const password = this.value("[data-nerve-password-input]")
const notes = this.value("[data-nerve-notes-input]")
if (!metadata.title || !metadata.title.trim()) {
return this.setError("Title is required.")
}
if (!password) return this.setError("A password is required.")
try {
const key = await this.key()
this.setHidden(
"[data-nerve-encrypted-metadata]",
await encryptValue(JSON.stringify(metadata), key, metadataAad())
)
this.setHidden(
"[data-nerve-encrypted-password]",
await encryptValue(password, key, entryAad(metadata, "password"))
)
this.setHidden(
"[data-nerve-encrypted-notes]",
notes ? await encryptValue(notes, key, entryAad(metadata, "notes")) : null
)
// Push ciphertext via LiveView event — avoid form re-render races wiping
// hidden fields that phx-change rebuilds.
const entry = {
title: metadata.title.trim(),
login_username: (metadata.login_username || "").trim(),
website: (metadata.website || "").trim(),
encrypted_metadata: await encryptValue(JSON.stringify(metadata), key, metadataAad()),
encrypted_password: await encryptValue(password, key, entryAad(metadata, "password")),
encrypted_notes: notes
? await encryptValue(notes, key, entryAad(metadata, "notes"))
: null
}
// Never let the plaintext reach the server.
this.setValue("[data-nerve-password-input]", "")
this.setValue("[data-nerve-notes-input]", "")
form.requestSubmit()
} catch (_error) {
this.setError("Could not encrypt this entry.")
this.pushEvent("create", { entry })
} catch (error) {
const msg = error && error.message ? String(error.message) : ""
if (msg.includes("vault-locked")) {
this.setError("Enter your account password first.")
} else {
this.setError("Could not encrypt this entry. Unlock again and retry.")
}
}
},

View file

@ -1,217 +1,495 @@
defmodule Elektrine.Captcha do
@moduledoc """
Simple server-side image captcha for Tor-protected auth flows.
Generates math problems rendered as images.
Server-side image captcha for no-JS / Tor auth flows.
Security properties:
- Opaque single-use challenge IDs (not an HMAC of the answer)
- Answer stored only as an HMAC hash in Cachex; removed on first verify
- Short TTL; rate limits on issue and verify
- Distorted image with noise against casual OCR
"""
@word_numbers %{
1 => "one",
2 => "two",
3 => "three",
4 => "four",
5 => "five",
6 => "six",
7 => "seven",
8 => "eight",
9 => "nine",
10 => "ten",
11 => "eleven",
12 => "twelve"
}
require Logger
@token_ttl_ms :timer.minutes(3)
@cache :app_cache
@issue_limit_per_ip 20
@issue_window_ms :timer.minutes(10)
@verify_limit_per_ip 40
@verify_window_ms :timer.minutes(10)
# Ambiguous glyphs removed: 0/O, 1/l/I, 5/S, 8/B, 2/Z
@charset ~c"ABCDEFGHJKLMNPQRTUVWXY34679"
@code_length 6..7
@doc """
Generates a new captcha.
Returns {image_binary, answer_string, token}
Returns `{png_binary, answer, token}`. `answer` is for tests only never
send it to clients. Pass `ip:` to enable issue rate limiting.
"""
def generate do
{text, answer} = generate_problem()
def generate(opts \\ []) do
ip = Keyword.get(opts, :ip)
# Generate token and sign the answer
token = generate_token(answer)
case check_issue_rate(ip) do
{:error, :rate_limited} = err ->
err
# Generate image
image_binary = render_captcha_image(text)
:ok ->
answer = generate_code()
token = new_token()
answer_norm = normalize_answer(answer)
{image_binary, answer, token}
end
store_challenge(token, %{
answer_hash: hash_answer(answer_norm),
issued_at: System.system_time(:second),
ip: ip
})
defp generate_problem do
case Enum.random(1..4) do
1 -> generate_word_math()
2 -> generate_mixed_math()
3 -> generate_multiply()
4 -> generate_two_step()
end
end
# "seven + 4 = ?" - mix words and digits
defp generate_word_math do
a = Enum.random(1..12)
b = Enum.random(1..12)
op = Enum.random([:add, :subtract])
a_str = if Enum.random([true, false]), do: @word_numbers[a], else: "#{a}"
b_str = if Enum.random([true, false]), do: @word_numbers[b], else: "#{b}"
case op do
:add ->
{"#{a_str} + #{b_str} = ?", Integer.to_string(a + b)}
:subtract ->
{max, min} = if a >= b, do: {a, b}, else: {b, a}
max_str = if Enum.random([true, false]), do: @word_numbers[max], else: "#{max}"
min_str = if Enum.random([true, false]), do: @word_numbers[min], else: "#{min}"
{"#{max_str} - #{min_str} = ?", Integer.to_string(max - min)}
end
end
# "3 x 4 = ?" - simple multiplication
defp generate_multiply do
a = Enum.random(2..9)
b = Enum.random(2..9)
op_symbol = Enum.random(["x", "*"])
{"#{a} #{op_symbol} #{b} = ?", Integer.to_string(a * b)}
end
# "5 + 3 - 2 = ?" - two operations
defp generate_two_step do
a = Enum.random(5..15)
b = Enum.random(1..5)
c = Enum.random(1..5)
case Enum.random(1..2) do
1 ->
# a + b - c
{"#{a} + #{b} - #{c} = ?", Integer.to_string(a + b - c)}
2 ->
# a - b + c (ensure positive intermediate)
if a > b do
{"#{a} - #{b} + #{c} = ?", Integer.to_string(a - b + c)}
else
{"#{a} + #{b} - #{c} = ?", Integer.to_string(a + b - c)}
end
end
end
# Regular mixed format
defp generate_mixed_math do
a = Enum.random(10..25)
b = Enum.random(3..12)
if a > b do
{"#{a} - #{b} = ?", Integer.to_string(a - b)}
else
{"#{a} + #{b} = ?", Integer.to_string(a + b)}
record_issue(ip)
image = render_captcha_image(answer)
{image, answer, token}
end
end
@doc """
Verifies a captcha answer against the token.
"""
def verify(token, user_answer) when is_binary(token) and is_binary(user_answer) do
user_answer = String.trim(user_answer)
Verifies and **consumes** a captcha challenge (single use).
with {:ok, decoded} <- Base.decode64(token),
[timestamp_str, answer_hash] <- String.split(decoded, ":", parts: 2),
{timestamp, ""} <- Integer.parse(timestamp_str),
:ok <- verify_timestamp(timestamp),
:ok <- verify_answer(user_answer, answer_hash) do
:ok
else
{:error, reason} -> {:error, reason}
_ -> {:error, :invalid_token}
Options:
- `:ip` client IP for rate limiting
"""
def verify(token, user_answer, opts \\ [])
def verify(token, user_answer, opts)
when is_binary(token) and is_binary(user_answer) do
ip = Keyword.get(opts, :ip)
user_answer = normalize_answer(user_answer)
result =
with :ok <- check_verify_rate(ip),
:ok <- nonempty_answer(user_answer),
{:ok, challenge} <- take_challenge(token),
:ok <- maybe_log_ip_mismatch(challenge, ip) do
match_answer(user_answer, challenge.answer_hash)
end
record_verify(ip)
result
end
def verify(_, _, _), do: {:error, :invalid_input}
# -- code generation --------------------------------------------------------
defp generate_code do
length = Enum.random(@code_length)
1..length
|> Enum.map(fn _ -> Enum.random(@charset) end)
|> List.to_string()
end
# -- storage / crypto -------------------------------------------------------
defp new_token do
:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
end
defp store_challenge(token, payload) do
case Cachex.put(@cache, challenge_key(token), payload, expire: @token_ttl_ms) do
{:ok, _} ->
:ok
other ->
Logger.warning("captcha store failed: #{inspect(other)}")
:ok
end
end
def verify(_, _), do: {:error, :invalid_input}
# Cachex.take/2 removes the key in one step — single-use challenges.
defp take_challenge(token) when is_binary(token) and byte_size(token) > 16 do
case Cachex.take(@cache, challenge_key(token)) do
{:ok, %{answer_hash: hash} = challenge} when is_binary(hash) ->
{:ok, challenge}
# Token expires after 5 minutes
@token_ttl 300
{:ok, nil} ->
{:error, :invalid_token}
defp generate_token(answer) do
timestamp = System.system_time(:second)
answer_hash = hash_answer(answer)
{:ok, _} ->
{:error, :invalid_token}
Base.encode64("#{timestamp}:#{answer_hash}")
{:error, _} ->
{:error, :invalid_token}
_ ->
{:error, :invalid_token}
end
end
defp take_challenge(_), do: {:error, :invalid_token}
defp challenge_key(token), do: {:captcha_challenge, token}
defp hash_answer(answer) do
secret = get_secret()
:crypto.mac(:hmac, :sha256, secret, answer)
:crypto.mac(:hmac, :sha256, secret, "captcha-v2:" <> answer)
|> Base.encode16(case: :lower)
end
defp get_secret do
Application.get_env(:elektrine, ElektrineWeb.Endpoint)[:secret_key_base]
Application.get_env(:elektrine, ElektrineWeb.Endpoint)[:secret_key_base] ||
"test_captcha_secret_key_base_do_not_use_in_prod"
end
defp verify_timestamp(timestamp) do
now = System.system_time(:second)
age = now - timestamp
if age >= 0 and age <= @token_ttl do
:ok
else
{:error, :captcha_expired}
end
defp normalize_answer(answer) when is_binary(answer) do
answer
|> String.trim()
|> String.downcase()
|> String.replace(~r/[\s_,]/, "")
end
defp verify_answer(user_answer, stored_hash) do
expected_hash = hash_answer(user_answer)
defp normalize_answer(_), do: ""
if byte_size(expected_hash) == byte_size(stored_hash) and
Plug.Crypto.secure_compare(expected_hash, stored_hash) do
defp nonempty_answer(""), do: {:error, :missing_captcha}
defp nonempty_answer(_), do: :ok
defp match_answer(user_answer, stored_hash) do
expected = hash_answer(user_answer)
if byte_size(expected) == byte_size(stored_hash) and
Plug.Crypto.secure_compare(expected, stored_hash) do
:ok
else
{:error, :wrong_answer}
end
end
defp maybe_log_ip_mismatch(%{ip: issued_ip}, ip)
when is_binary(issued_ip) and is_binary(ip) and issued_ip != "" and issued_ip != ip do
Logger.info("captcha ip mismatch issued=#{issued_ip} present=#{ip}")
:ok
end
defp maybe_log_ip_mismatch(_challenge, _ip), do: :ok
# -- rate limits ------------------------------------------------------------
defp check_issue_rate(nil), do: :ok
defp check_issue_rate(ip) when is_binary(ip) do
case Cachex.get(@cache, rate_key(:issue, ip)) do
{:ok, n} when is_integer(n) and n >= @issue_limit_per_ip -> {:error, :rate_limited}
_ -> :ok
end
end
defp check_issue_rate(_), do: :ok
defp record_issue(nil), do: :ok
defp record_issue(ip) when is_binary(ip) do
key = rate_key(:issue, ip)
case Cachex.incr(@cache, key) do
{:ok, 1} -> _ = Cachex.expire(@cache, key, @issue_window_ms)
_ -> :ok
end
rescue
_ -> :ok
end
defp record_issue(_), do: :ok
defp check_verify_rate(nil), do: :ok
defp check_verify_rate(ip) when is_binary(ip) do
case Cachex.get(@cache, rate_key(:verify, ip)) do
{:ok, n} when is_integer(n) and n >= @verify_limit_per_ip -> {:error, :rate_limited}
_ -> :ok
end
end
defp check_verify_rate(_), do: :ok
defp record_verify(nil), do: :ok
defp record_verify(ip) when is_binary(ip) do
key = rate_key(:verify, ip)
case Cachex.incr(@cache, key) do
{:ok, 1} -> _ = Cachex.expire(@cache, key, @verify_window_ms)
_ -> :ok
end
rescue
_ -> :ok
end
defp record_verify(_), do: :ok
defp rate_key(kind, ip), do: {:captcha_rate, kind, ip}
# -- image rendering --------------------------------------------------------
defp render_captcha_image(text) do
# Create a simple captcha image using Image library
# White background, black text, some noise
width = 320
height = 100
width = 200
height = 60
{:ok, bg} = Image.new(width, height, color: random_bg_color())
bg = paint_gradient_noise(bg, width, height)
bg = paint_noise(bg, width, height, 280)
bg = add_noise_lines(bg, width, height, 18)
bg = add_noise_circles(bg, width, height, 12)
bg = add_decoy_glyphs(bg, width, height)
# Create base image with slight gradient/noise background
{:ok, bg} = Image.new(width, height, color: [240, 240, 245])
# Add some random noise lines
bg = add_noise_lines(bg, width, height)
# Render text
text_image =
Image.Text.text!(text,
font_size: 28,
text_fill_color: [30, 30, 40],
padding: [15, 10]
)
text
|> render_warped_text()
|> maybe_shear()
|> maybe_global_rotate()
# Compose text ON TOP of background
{:ok, final} = Image.compose(bg, text_image, x: 20, y: 10)
{tw, th} = image_size(text_image)
x = max(2, div(width - tw, 2) + Enum.random(-18..18))
y = max(2, div(height - th, 2) + Enum.random(-12..12))
# Convert to PNG binary
{:ok, binary} = Image.write(final, :memory, suffix: ".png")
{:ok, composed} = Image.compose(bg, text_image, x: x, y: y)
# Strike-through noise across the text band — hard for OCR, still readable.
composed = strike_through_band(composed, width, height)
composed = add_noise_lines(composed, width, height, 10)
composed = paint_noise(composed, width, height, 80)
composed = maybe_blur_light(composed)
{:ok, binary} = Image.write(composed, :memory, suffix: ".png")
binary
end
defp add_noise_lines(image, width, height) do
# Add random lines to make OCR harder
Enum.reduce(1..5, image, fn _, img ->
x1 = Enum.random(0..width)
y1 = Enum.random(0..height)
x2 = Enum.random(0..width)
y2 = Enum.random(0..height)
# Vary the gray intensity
gray = Enum.random(120..180)
defp render_warped_text(text) do
base_size = Enum.random(28..34)
case Image.Draw.line(img, x1, y1, x2, y2, color: [gray, gray, gray]) do
{:ok, new_img} -> new_img
glyphs =
text
|> String.graphemes()
|> Enum.reject(&(&1 in ["", " "]))
|> Enum.map(fn g ->
# Per-glyph color and size so simple thresholding fails.
fill = [
Enum.random(5..70),
Enum.random(5..70),
Enum.random(15..90)
]
glyph =
Image.Text.simple_text!(g,
font_size: base_size + Enum.random(-4..6),
text_fill_color: fill
)
angle = Enum.random(-28..28) * 1.0
glyph =
case Image.rotate(glyph, angle, background_color: [0, 0, 0, 0]) do
{:ok, rotated} -> rotated
_ -> glyph
end
{:glyph, glyph}
end)
spacing = Enum.random(0..4)
canvas_h = 80
{row, final_x} =
Enum.reduce(glyphs, {nil, 0}, fn {:glyph, glyph}, {acc, x} ->
y_off = Enum.random(0..22)
{gw, _gh} = image_size(glyph)
# Occasional overlap with previous glyph
x = if Enum.random(1..4) == 1, do: max(0, x - Enum.random(2..6)), else: x
case acc do
nil ->
{:ok, canvas} =
Image.new(max(gw + 280, 300), canvas_h, color: [0, 0, 0, 0], bands: 4)
{:ok, placed} = Image.compose(canvas, glyph, x: 6, y: y_off)
{placed, 6 + gw + spacing}
canvas ->
{:ok, placed} = Image.compose(canvas, glyph, x: x, y: y_off)
{placed, x + gw + spacing}
end
end)
case row do
nil ->
Image.Text.simple_text!(text, font_size: base_size, text_fill_color: [20, 20, 30])
img ->
# Tight crop to used width so placement stays centered.
crop_w = min(Image.width(img), final_x + 8)
crop_h = Image.height(img)
case Image.crop(img, 0, 0, crop_w, crop_h) do
{:ok, cropped} -> cropped
_ -> img
end
end
rescue
error ->
Logger.warning("captcha glyph render fell back: #{inspect(error)}")
Image.Text.text!(text,
font_size: 30,
text_fill_color: [20, 20, 30],
padding: [10, 8]
)
end
defp maybe_shear(image) do
sx = Enum.random(-18..18) / 100
sy = Enum.random(-10..10) / 100
case Image.shear(image, sx, sy, background_color: [0, 0, 0, 0]) do
{:ok, sheared} -> sheared
_ -> image
end
rescue
_ -> image
end
defp maybe_global_rotate(image) do
angle = Enum.random(-8..8) * 1.0
case Image.rotate(image, angle, background_color: [0, 0, 0, 0]) do
{:ok, rotated} -> rotated
_ -> image
end
rescue
_ -> image
end
defp strike_through_band(image, width, height) do
mid = div(height, 2)
Enum.reduce(1..5, image, fn _, img ->
y1 = mid + Enum.random(-14..14)
y2 = mid + Enum.random(-14..14)
x1 = Enum.random(0..div(width, 4))
x2 = Enum.random(div(width * 3, 4)..width)
c = Enum.random(40..120)
case Image.Draw.line(img, x1, y1, x2, y2, color: [c, c + Enum.random(0..30), c]) do
{:ok, next} -> next
_ -> img
end
end)
end
defp add_decoy_glyphs(image, width, height) do
Enum.reduce(1..5, image, fn _, img ->
ch = List.to_string([Enum.random(@charset)])
size = Enum.random(14..22)
fill = [Enum.random(140..200), Enum.random(140..200), Enum.random(140..200)]
try do
decoy = Image.Text.simple_text!(ch, font_size: size, text_fill_color: fill)
angle = Enum.random(-40..40) * 1.0
decoy =
case Image.rotate(decoy, angle, background_color: [0, 0, 0, 0]) do
{:ok, r} -> r
_ -> decoy
end
x = Enum.random(0..max(1, width - 30))
y = Enum.random(0..max(1, height - 30))
case Image.compose(img, decoy, x: x, y: y) do
{:ok, next} -> next
_ -> img
end
rescue
_ -> img
end
end)
end
defp paint_gradient_noise(image, width, height) do
Enum.reduce(0..(height - 1)//3, image, fn y, img ->
shade = Enum.random(200..245)
color = [shade, shade - Enum.random(0..12), shade - Enum.random(0..18)]
case Image.Draw.line(img, 0, y, width, y, color: color) do
{:ok, next} -> next
_ -> img
end
end)
rescue
_ -> image
end
defp paint_noise(image, width, height, count) do
Enum.reduce(1..count, image, fn _, img ->
x = Enum.random(0..(width - 1))
y = Enum.random(0..(height - 1))
gray = Enum.random(40..230)
case Image.Draw.point(img, x, y,
color: [gray, gray + Enum.random(-20..20), gray + Enum.random(-20..20)]
) do
{:ok, next} -> next
_ -> img
end
end)
rescue
_ -> image
end
defp add_noise_lines(image, width, height, count) do
Enum.reduce(1..count, image, fn _, img ->
x1 = Enum.random(0..width)
y1 = Enum.random(0..height)
x2 = Enum.random(0..width)
y2 = Enum.random(0..height)
c = Enum.random(50..200)
case Image.Draw.line(img, x1, y1, x2, y2, color: [c, max(0, c - 25), min(255, c + 25)]) do
{:ok, next} -> next
_ -> img
end
end)
end
defp add_noise_circles(image, width, height, count) do
Enum.reduce(1..count, image, fn _, img ->
cx = Enum.random(0..width)
cy = Enum.random(0..height)
r = Enum.random(4..22)
c = Enum.random(70..210)
case Image.Draw.circle(img, cx, cy, r, color: [c, c, c], fill: false) do
{:ok, next} -> next
_ -> img
end
end)
rescue
_ -> image
end
defp maybe_blur_light(image) do
sigma = Enum.random([0.35, 0.5, 0.65])
case Image.blur(image, sigma: sigma) do
{:ok, blurred} -> blurred
_ -> image
end
end
defp random_bg_color do
base = Enum.random(200..240)
[base, base - Enum.random(0..25), base - Enum.random(0..30)]
end
defp image_size(image) do
{Image.width(image), Image.height(image)}
end
end

View file

@ -196,8 +196,11 @@ defmodule Elektrine.Paths do
def login_path, do: "/login"
def lite_path, do: "/lite"
def lite_login_path, do: "/lite/login"
def lite_register_path, do: "/lite/register"
def lite_feed_path, do: "/lite/feed"
def lite_email_path, do: "/lite/email"
def lite_compose_path, do: "/lite/compose"
def lite_account_path, do: "/lite/account"
def timeline_path, do: "/timeline"
def timeline_path(params) when is_list(params), do: with_query(timeline_path(), params)
def chat_root_path, do: "/chat"

View file

@ -1,17 +1,36 @@
defmodule Elektrine.CaptchaTest do
use ExUnit.Case, async: true
use ExUnit.Case, async: false
alias Elektrine.Captcha
test "generated captcha verifies with its answer" do
{_image_binary, answer, token} = Captcha.generate()
{image, answer, token} = Captcha.generate()
assert is_binary(image)
assert byte_size(image) > 100
assert :ok = Captcha.verify(token, answer)
end
test "malformed answer hashes fail closed instead of raising" do
token = Base.encode64("#{System.system_time(:second)}:short")
test "challenge is single-use" do
{_image, answer, token} = Captcha.generate()
assert {:error, :wrong_answer} = Captcha.verify(token, "1")
assert :ok = Captcha.verify(token, answer)
assert {:error, :invalid_token} = Captcha.verify(token, answer)
end
test "wrong answer consumes the challenge" do
{_image, _answer, token} = Captcha.generate()
assert {:error, :wrong_answer} = Captcha.verify(token, "99999")
assert {:error, :invalid_token} = Captcha.verify(token, "99999")
end
test "unknown token fails closed" do
assert {:error, :invalid_token} = Captcha.verify("not-a-real-token-aaaaaaaaaaaaaaaa", "1")
end
test "empty answer is rejected" do
{_image, _answer, token} = Captcha.generate()
assert {:error, :missing_captcha} = Captcha.verify(token, " ")
end
end

View file

@ -21,17 +21,21 @@ defmodule ElektrineEmailWeb.LiteEmailController do
per_page = 25
listing = list_folder(mailbox.id, folder, page, per_page)
inbox_unread = Email.unread_inbox_count(mailbox.id)
render(conn, :index,
page_title: "Mail · #{folder}",
folder: folder,
listing: listing,
mailbox: mailbox
mailbox: mailbox,
inbox_unread: inbox_unread,
credit_balance: credit_balance(conn.assigns.current_user)
)
end
def show(conn, %{"id" => id}) do
def show(conn, %{"id" => id} = params) do
user = conn.assigns.current_user
folder = normalize_folder(params["folder"])
with {message_id, ""} <- Integer.parse(to_string(id)),
{:ok, message} <- Email.get_user_message(message_id, user.id) do
@ -40,7 +44,8 @@ defmodule ElektrineEmailWeb.LiteEmailController do
render(conn, :show,
page_title: message.subject || "(no subject)",
message: message,
mailbox: conn.assigns.mailbox
mailbox: conn.assigns.mailbox,
folder: folder
)
else
_ ->
@ -51,23 +56,15 @@ defmodule ElektrineEmailWeb.LiteEmailController do
end
def compose(conn, params) do
user = conn.assigns.current_user
balance =
if Code.ensure_loaded?(Atomine.Credits) do
Atomine.Credits.balance(user.id, :atomine_credit)
else
0
end
render(conn, :compose,
page_title: "Compose",
mailbox: conn.assigns.mailbox,
to: params["to"] || "",
subject: params["subject"] || "",
body: params["body"] || "",
cc: params["cc"] || "",
in_reply_to: params["in_reply_to"],
credit_balance: balance
credit_balance: credit_balance(conn.assigns.current_user)
)
end
@ -97,10 +94,7 @@ defmodule ElektrineEmailWeb.LiteEmailController do
{:error, :insufficient_email_credits} ->
conn
|> put_flash(
:error,
"Need Identity Credits for external mail. Earn them on the full site (Account → Identity) with browser work or verified proofs — that requires JavaScript."
)
|> put_flash(:error, "Not enough credits for external mail.")
|> redirect(to: ~p"/lite/email/compose")
{:error, reason} ->
@ -133,6 +127,23 @@ defmodule ElektrineEmailWeb.LiteEmailController do
end
end
def mark_unread(conn, %{"id" => id}) do
user = conn.assigns.current_user
with {message_id, ""} <- Integer.parse(to_string(id)),
{:ok, message} <- Email.get_user_message(message_id, user.id),
{:ok, _} <- Email.mark_as_unread(message) do
conn
|> put_flash(:info, "Marked unread.")
|> redirect(to: ~p"/lite/email")
else
_ ->
conn
|> put_flash(:error, "Could not mark unread.")
|> redirect(to: ~p"/lite/email")
end
end
def reply(conn, %{"id" => id}) do
user = conn.assigns.current_user
@ -226,4 +237,12 @@ defmodule ElektrineEmailWeb.LiteEmailController do
defp format_time(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC")
defp format_time(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
defp format_time(_), do: ""
defp credit_balance(user) do
if Code.ensure_loaded?(Atomine.Credits) do
Atomine.Credits.balance(user.id, :atomine_credit)
else
0
end
end
end

View file

@ -37,9 +37,4 @@ defmodule ElektrineEmailWeb.LiteEmailHTML do
"(empty body)"
end
end
def folder_link(folder, current) do
base = "text-sm link link-hover"
if folder == current, do: base <> " font-semibold", else: base
end
end

View file

@ -1,69 +1,47 @@
<section class="space-y-4">
<p class="text-sm">
<a href={~p"/lite/email"} class="link link-hover">← Mail</a>
<section class="lite-stack">
<p class="lite-muted">
<a href={~p"/lite/email"}>← Mail</a>
</p>
<h1 class="text-xl font-semibold">Compose</h1>
<p class="text-sm text-base-content/60">
From <code>{@mailbox.email}</code> · plain text only
<h1 class="lite-h1">Compose</h1>
<p class="lite-muted">
From <code>{@mailbox.email}</code>
· credits: <strong>{@credit_balance}</strong>
</p>
<div class="rounded-lg border border-base-300 bg-base-200/40 px-3 py-2 text-sm space-y-1">
<p>
Identity Credits: <strong>{@credit_balance}</strong>
<span class="text-base-content/55">(required for external recipients)</span>
</p>
<p class="text-base-content/60">
Earn credits on the
<a href={~p"/proofs"} class="link link-hover">full site Identity page</a>
(browser work or verified proofs — needs JavaScript). Internal mail does not
spend credits.
</p>
</div>
<form action={~p"/lite/email/compose"} method="post" class="space-y-3">
<form action={~p"/lite/email/compose"} method="post" class="lite-form">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<%= if @in_reply_to not in [nil, ""] do %>
<input type="hidden" name="email[in_reply_to]" value={@in_reply_to} />
<% end %>
<label class="form-control w-full">
<span class="label-text text-sm">To</span>
<label class="lite-label">
<span>To</span>
<input
type="text"
name="email[to]"
required
value={@to}
class="input input-bordered w-full"
class="lite-input"
autocomplete="email"
/>
</label>
<label class="form-control w-full">
<span class="label-text text-sm">Cc (optional)</span>
<input type="text" name="email[cc]" class="input input-bordered w-full" />
<label class="lite-label">
<span>Cc</span>
<input type="text" name="email[cc]" class="lite-input" value={@cc || ""} />
</label>
<label class="form-control w-full">
<span class="label-text text-sm">Subject</span>
<input
type="text"
name="email[subject]"
value={@subject}
class="input input-bordered w-full"
/>
<label class="lite-label">
<span>Subject</span>
<input type="text" name="email[subject]" value={@subject} class="lite-input" />
</label>
<label class="form-control w-full">
<span class="label-text text-sm">Body</span>
<textarea
name="email[body]"
rows="14"
required
class="textarea textarea-bordered w-full font-mono text-sm"
>{@body}</textarea>
<label class="lite-label">
<span>Body</span>
<textarea name="email[body]" rows="14" required class="lite-textarea">{@body}</textarea>
</label>
<button type="submit" class="btn btn-primary">Send</button>
<button type="submit" class="lite-btn lite-btn-primary">Send</button>
</form>
</section>

View file

@ -1,51 +1,52 @@
<section class="space-y-4">
<header class="flex flex-wrap items-end justify-between gap-3">
<section class="lite-stack">
<header class="lite-row">
<div>
<h1 class="text-xl font-semibold">Mail</h1>
<p class="text-sm text-base-content/60">{@mailbox.email}</p>
<h1 class="lite-h1">Mail</h1>
<p class="lite-muted">{@mailbox.email}</p>
</div>
<a href={~p"/lite/email/compose"} class="btn btn-primary btn-sm">Compose</a>
<a href={~p"/lite/email/compose"} class="lite-btn lite-btn-primary lite-btn-sm">Compose</a>
</header>
<nav class="flex flex-wrap gap-3 border-b border-base-300 pb-2" aria-label="Folders">
<p class="lite-xs">Credits: {@credit_balance}</p>
<nav class="lite-tabs" aria-label="Folders">
<%= for folder <- ~w(inbox sent drafts trash spam archived) do %>
<a href={~p"/lite/email?folder=#{folder}"} class={folder_link(folder, @folder)}>
<a
href={~p"/lite/email?folder=#{folder}"}
aria-current={if(folder == @folder, do: "page", else: nil)}
>
{String.capitalize(folder)}
<%= if folder == "inbox" and @inbox_unread > 0 do %>
<span class="lite-badge">{@inbox_unread}</span>
<% end %>
</a>
<% end %>
</nav>
<%= if @listing.messages == [] do %>
<p class="text-sm text-base-content/60">No messages in {String.capitalize(@folder)}.</p>
<p class="lite-muted">Empty.</p>
<% else %>
<ul class="divide-y divide-base-300 rounded-lg border border-base-300">
<ul class="lite-list">
<%= for message <- @listing.messages do %>
<li class={[
"p-3",
if(message.read, do: "opacity-80", else: "bg-base-200/30")
]}>
<a href={~p"/lite/email/#{message.id}"} class="block space-y-1">
<div class="flex flex-wrap items-baseline justify-between gap-2 text-sm">
<span class={if(message.read, do: "", else: "font-semibold")}>
{preview_from(message)}
</span>
<span class="text-xs text-base-content/50">
<li class={if(message.read, do: nil, else: "lite-list-item-unread")}>
<a href={~p"/lite/email/#{message.id}"} class="lite-block lite-stack-sm">
<div class="lite-row">
<span class="lite-item-title">{preview_from(message)}</span>
<span class="lite-item-meta">
{Calendar.strftime(message.inserted_at, "%Y-%m-%d %H:%M")}
</span>
</div>
<p class={["text-sm", if(message.read, do: "", else: "font-medium")]}>
{preview_subject(message)}
</p>
<p class="lite-item-title" style="margin:0">{preview_subject(message)}</p>
</a>
</li>
<% end %>
</ul>
<div class="flex gap-3 pt-2 text-sm">
<div class="lite-pager">
<%= if @listing.has_prev do %>
<a
href={~p"/lite/email?folder=#{@folder}&page=#{@listing.page - 1}"}
class="btn btn-sm btn-outline"
class="lite-btn lite-btn-sm"
>
Newer
</a>
@ -53,12 +54,12 @@
<%= if @listing.has_next do %>
<a
href={~p"/lite/email?folder=#{@folder}&page=#{@listing.page + 1}"}
class="btn btn-sm btn-outline"
class="lite-btn lite-btn-sm"
>
Older
</a>
<% end %>
<span class="self-center text-xs text-base-content/50">
<span class="lite-xs">
Page {@listing.page}
<%= if @listing.total_pages > 0 do %>
/ {@listing.total_pages}

View file

@ -1,40 +1,40 @@
<article class="space-y-4">
<p class="text-sm">
<a href={~p"/lite/email"} class="link link-hover">← Inbox</a>
<article class="lite-stack">
<p class="lite-muted">
<a href={~p"/lite/email?folder=#{@folder}"}>← {String.capitalize(@folder)}</a>
</p>
<header class="space-y-1 border-b border-base-300 pb-3">
<h1 class="text-xl font-semibold tracking-tight">
{@message.subject || "(no subject)"}
</h1>
<p class="text-sm">
<span class="text-base-content/55">From</span> {@message.from}
<header class="lite-article-header lite-stack-sm">
<h1 class="lite-h1">{@message.subject || "(no subject)"}</h1>
<p class="lite-muted">
<span>From</span> {@message.from}
</p>
<p class="text-sm">
<span class="text-base-content/55">To</span> {@message.to}
<p class="lite-muted">
<span>To</span> {@message.to}
</p>
<%= if is_binary(@message.cc) and @message.cc != "" do %>
<p class="text-sm">
<span class="text-base-content/55">Cc</span> {@message.cc}
<p class="lite-muted">
<span>Cc</span> {@message.cc}
</p>
<% end %>
<p class="text-xs text-base-content/50">
<p class="lite-xs">
{Calendar.strftime(@message.inserted_at, "%Y-%m-%d %H:%M UTC")}
</p>
</header>
<div class="whitespace-pre-wrap text-sm leading-7 text-base-content/90">
{body_text(@message)}
</div>
<div class="lite-body">{body_text(@message)}</div>
<div class="flex flex-wrap gap-2 border-t border-base-300 pt-3">
<a href={~p"/lite/email/#{@message.id}/reply"} class="btn btn-sm btn-primary">Reply</a>
<form action={~p"/lite/email/#{@message.id}/trash"} method="post" class="inline">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<button type="submit" class="btn btn-sm btn-outline">Trash</button>
</form>
<a href={~p"/email/view/#{@message.id}"} class="btn btn-sm btn-ghost">
Full mail UI
<div class="lite-actions">
<a href={~p"/lite/email/#{@message.id}/reply"} class="lite-btn lite-btn-primary lite-btn-sm">
Reply
</a>
<form action={~p"/lite/email/#{@message.id}/trash"} method="post" class="lite-inline-form">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<button type="submit" class="lite-btn lite-btn-sm">Trash</button>
</form>
<form action={~p"/lite/email/#{@message.id}/unread"} method="post" class="lite-inline-form">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<button type="submit" class="lite-btn lite-btn-sm">Mark unread</button>
</form>
<a href={~p"/email/view/#{@message.id}"} class="lite-btn lite-btn-sm">Full UI</a>
</div>
</article>

View file

@ -46,14 +46,24 @@ defmodule ElektrineNerveWeb.NerveLive do
else
{:error, :invalid_payload} ->
{:noreply,
put_flash(socket, :error, "Nerve payload is invalid. Unlock Nerve and try again.")}
put_flash(
socket,
:error,
"Could not save entry (bad encrypted payload). Unlock with your account password and try again."
)}
{:error, :nerve_not_configured} ->
{:noreply,
put_flash(socket, :error, "Set up account-password encryption before saving entries.")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(%{changeset | action: :insert}, as: :entry))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply,
socket
|> assign(:form, to_form(%{changeset | action: :insert}, as: :entry))
|> put_flash(:error, changeset_flash(changeset))}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Could not save Nerve entry.")}
end
end
@ -542,6 +552,17 @@ defmodule ElektrineNerveWeb.NerveLive do
end)
end
defp changeset_flash(%Ecto.Changeset{} = changeset) do
case Ecto.Changeset.traverse_errors(changeset, &translate_error/1) do
errors when map_size(errors) > 0 ->
{field, [msg | _]} = Enum.at(errors, 0)
"#{field}: #{msg}"
_ ->
"Could not save Nerve entry."
end
end
defp format_inserted_at(nil), do: "-"
defp format_inserted_at(datetime), do: Calendar.strftime(datetime, "%Y-%m-%d")

View file

@ -735,6 +735,15 @@
</.link>
</li>
<li>
<.link
href={~p"/lite/prefer-lite"}
class="link link-hover text-base-content/70 transition-colors hover:text-base-content"
>
Prefer lite
</.link>
</li>
<li>
<.link
href={Elektrine.Paths.vpn_policy_path()}

View file

@ -1,41 +1,37 @@
<div class="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
<header class="mb-8 border-b border-base-300 pb-4">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<a href={~p"/lite"} class="text-lg font-semibold tracking-tight">
Elektrine <span class="text-sm font-normal text-base-content/60">lite</span>
</a>
<nav class="flex flex-wrap gap-3 text-sm" aria-label="Lite navigation">
<a href={~p"/lite"} class="link link-hover">Home</a>
<a href={~p"/lite/feed"} class="link link-hover">Feed</a>
<%= if @current_user do %>
<a href={~p"/lite/email"} class="link link-hover">Mail</a>
<% end %>
<a href={~p"/canary"} class="link link-hover">Canary</a>
<%= if @current_user do %>
<span class="text-base-content/70">{@current_user.username}</span>
<form action={~p"/logout"} method="post" class="inline">
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<button type="submit" class="link link-hover">Sign out</button>
</form>
<% else %>
<a href={~p"/lite/login"} class="link link-hover">Sign in</a>
<% end %>
<a href={~p"/"} class="link link-hover opacity-70">Full site</a>
</nav>
</div>
<p class="mt-2 text-xs text-base-content/55">
Privacy mode: HTML and CSS only. No JavaScript is loaded. Features are limited.
</p>
<div class="lite-wrap">
<header class="lite-header">
<a href={~p"/lite"} class="lite-brand">
Elektrine <span>lite</span>
</a>
<nav class="lite-nav" aria-label="Lite navigation">
<a href={~p"/lite"}>Home</a>
<a href={~p"/lite/feed"}>Feed</a>
<%= if @current_user do %>
<a href={~p"/lite/compose"}>Post</a>
<a href={~p"/lite/email"}>Mail</a>
<a href={~p"/lite/account"}>Account</a>
<span class="lite-nav-user">{@current_user.username}</span>
<form action={~p"/logout"} method="post" class="lite-inline-form">
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<button type="submit">Sign out</button>
</form>
<% else %>
<a href={~p"/lite/login"}>Sign in</a>
<a href={~p"/lite/register"}>Register</a>
<% end %>
<a href={~p"/lite/canary"}>Canary</a>
<a href={~p"/lite/prefer-full"}>Prefer full</a>
</nav>
</header>
<%= if Phoenix.Flash.get(@flash, :info) do %>
<p class="mb-4 rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-sm">
<p class="lite-flash lite-flash-info" role="status">
{Phoenix.Flash.get(@flash, :info)}
</p>
<% end %>
<%= if Phoenix.Flash.get(@flash, :error) do %>
<p class="mb-4 rounded-lg border border-error/30 bg-error/10 px-3 py-2 text-sm">
<p class="lite-flash lite-flash-error" role="alert">
{Phoenix.Flash.get(@flash, :error)}
</p>
<% end %>
@ -44,10 +40,10 @@
{@inner_content}
</main>
<footer class="mt-12 border-t border-base-300 pt-4 text-xs text-base-content/50">
<footer class="lite-footer">
<p>
Prefer the full LiveView app? <a href={~p"/"} class="link link-hover">Open full site</a>
(requires JavaScript).
<a href={~p"/lite/prefer-full"}>Prefer full</a>
· <a href={~p"/lite/prefer-lite"}>Prefer lite</a>
</p>
</footer>
</div>

View file

@ -1,19 +1,17 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<meta name="robots" content="noindex,nofollow" />
<meta
name="description"
content="Elektrine privacy mode — HTML only, no JavaScript required."
/>
<meta name="description" content="Elektrine lite" />
<meta name="color-scheme" content="dark" />
<title>{Map.get(assigns, :page_title, "Lite")} · Elektrine</title>
<link rel="icon" type="image/x-icon" href={~p"/images/favicon.ico"} />
<link rel="stylesheet" href={~p"/assets/app.css"} />
<link rel="stylesheet" href={~p"/assets/lite.css"} />
</head>
<body class="bg-base-100 text-base-content min-h-screen">
<body>
{@inner_content}
</body>
</html>

View file

@ -67,13 +67,13 @@ defmodule ElektrineWeb.API.AccountRegistrationController do
if value in [true, "true", "1", 1, "on"], do: "true", else: value
end
defp verify_security_check(params, _remote_ip, true) do
defp verify_security_check(params, remote_ip, true) do
captcha_token = source_param(params, "captcha_token")
captcha_answer =
source_param(params, "captcha_answer") || source_param(params, "captcha_solution")
case Elektrine.Captcha.verify(captcha_token, captcha_answer) do
case Elektrine.Captcha.verify(captcha_token, captcha_answer, ip: remote_ip) do
:ok -> :ok
error -> error
end

View file

@ -174,15 +174,23 @@ defmodule ElektrineWeb.API.UtilityController do
end
def captcha(conn, _params) do
{image_binary, _answer, token} = Captcha.generate()
ip = ElektrineWeb.ClientIP.client_ip(conn)
json(conn, %{
type: "native",
token: token,
answer_data: token,
url: "data:image/png;base64,#{Base.encode64(image_binary)}",
seconds_valid: @captcha_seconds_valid
})
case Captcha.generate(ip: ip) do
{image_binary, _answer, token} ->
json(conn, %{
type: "native",
token: token,
answer_data: token,
url: "data:image/png;base64,#{Base.encode64(image_binary)}",
seconds_valid: @captcha_seconds_valid
})
{:error, :rate_limited} ->
conn
|> put_status(:too_many_requests)
|> json(%{error: "rate_limited"})
end
end
def healthcheck(conn, _params) do

View file

@ -2,19 +2,30 @@ defmodule ElektrineWeb.CaptchaController do
use ElektrineWeb, :controller
alias Elektrine.Captcha
alias ElektrineWeb.ClientIP
@doc """
Generates and serves a captcha image.
Stores the token in session for later verification.
Stores the challenge token in the session for later verification.
"""
def show(conn, _params) do
{image_binary, _answer, token} = Captcha.generate()
ip = ClientIP.client_ip(conn)
conn
|> put_session(:captcha_token, token)
|> put_resp_content_type("image/png")
|> put_resp_header("cache-control", "no-store, no-cache, must-revalidate")
|> put_resp_header("pragma", "no-cache")
|> send_resp(200, image_binary)
case Captcha.generate(ip: ip) do
{image_binary, _answer, token} ->
conn
|> put_session(:captcha_token, token)
|> put_resp_content_type("image/png")
|> put_resp_header("cache-control", "no-store, no-cache, must-revalidate, private")
|> put_resp_header("pragma", "no-cache")
|> put_resp_header("x-content-type-options", "nosniff")
|> send_resp(200, image_binary)
{:error, :rate_limited} ->
conn
|> put_status(:too_many_requests)
|> put_resp_content_type("text/plain")
|> send_resp(429, "Too many captcha requests. Try again later.")
end
end
end

View file

@ -2,25 +2,45 @@ defmodule ElektrineWeb.LiteController do
@moduledoc """
Privacy-oriented HTML surface with no JavaScript.
Read-only public feed/post/remote actor pages plus a classic login form.
Interactive LiveView features stay on the full site.
Public feed/post/remote/local profile pages, classic login, plain-text
compose for signed-in users, and lite preference cookies for Tor/clearnet.
"""
use ElektrineWeb, :controller
import Ecto.Query
alias Elektrine.Accounts
alias Elektrine.Accounts.RecoveryEmailVerification
alias Elektrine.Accounts.User
alias Elektrine.ActivityPub
alias Elektrine.Auth.RateLimiter
alias Elektrine.Captcha
alias Elektrine.Repo
alias Elektrine.Social.Message
alias Elektrine.Telemetry.Events
alias ElektrineWeb.ClientIP
alias ElektrineWeb.UserAuth
require Logger
@lite_pref_cookie "elektrine_lite_pref"
@lite_pref_max_age 180 * 24 * 60 * 60
plug(:put_lite_layouts)
plug(:put_user_return_to when action in [:login])
plug(
:put_user_return_to
when action in [:login, :create_login, :register, :create_registration]
)
plug(:require_lite_user when action in [:compose, :create_post, :account, :update_account])
def home(conn, _params) do
render(conn, :home,
page_title: "Lite",
via_tor: conn.assigns[:via_tor] == true,
onion_host: lite_onion_host()
onion_host: lite_onion_host(),
current_user: conn.assigns[:current_user]
)
end
@ -32,6 +52,86 @@ defmodule ElektrineWeb.LiteController do
end
end
def create_login(conn, %{"user" => user_params}) when is_map(user_params) do
username = Map.get(user_params, "username")
password = Map.get(user_params, "password")
if is_binary(username) and is_binary(password) and username != "" and password != "" do
ip = ClientIP.client_ip(conn)
case check_login_rate_limits(ip, username) do
{:ok, :allowed} ->
authenticate_lite_login(conn, username, password, user_params, ip)
{:error, {:rate_limited, retry_after, _}} ->
conn
|> put_flash(:error, "Too many attempts. Try again in #{retry_after}s.")
|> render(:login, page_title: "Sign in")
end
else
conn
|> put_flash(:error, "Username and password required.")
|> render(:login, page_title: "Sign in")
end
end
def create_login(conn, _params) do
conn
|> put_flash(:error, "Invalid form.")
|> render(:login, page_title: "Sign in")
end
def register(conn, params) do
if conn.assigns[:current_user] do
redirect(conn, to: ~p"/lite")
else
render_register_form(conn, %{}, params["invite_code"], nil)
end
end
def create_registration(conn, %{"user" => user_params} = params) when is_map(user_params) do
if conn.assigns[:current_user] do
redirect(conn, to: ~p"/lite")
else
remote_ip = ClientIP.client_ip(conn)
registration_ip = normalize_ipv6_subnet(remote_ip)
via_tor = conn.assigns[:via_tor] == true
captcha_answer = Map.get(params, "captcha_answer")
captcha_token = Map.get(params, "captcha_token")
user_params =
user_params
|> Map.put("registration_ip", registration_ip)
|> Map.put("registered_via_onion", via_tor)
changeset = User.registration_changeset(%User{}, user_params)
if changeset.valid? do
case verify_lite_captcha(conn, captcha_token, captcha_answer) do
:ok ->
conn = delete_session(conn, :captcha_token)
finish_lite_registration(conn, user_params)
{:error, reason} ->
changeset =
%User{}
|> Accounts.change_user_registration(user_params)
|> Ecto.Changeset.add_error(:captcha, captcha_error_message(reason))
render_register_form(conn, user_params, nil, changeset)
end
else
render_register_form(conn, user_params, nil, changeset)
end
end
end
def create_registration(conn, _params) do
conn
|> put_flash(:error, "Invalid form.")
|> redirect(to: ~p"/lite/register")
end
def feed(conn, params) do
limit = clamp_limit(params["limit"], 25)
before_id = parse_positive_int(params["before"])
@ -43,16 +143,21 @@ defmodule ElektrineWeb.LiteController do
page_title: "Public feed",
posts: posts,
next_before: next_before,
limit: limit
limit: limit,
current_user: conn.assigns[:current_user]
)
end
def post(conn, %{"id" => id}) do
case fetch_public_post(id) do
%Message{} = message ->
replies = list_public_replies(message.id, 40)
render(conn, :post,
page_title: post_title(message),
post: message
post: message,
replies: replies,
current_user: conn.assigns[:current_user]
)
nil ->
@ -63,6 +168,22 @@ defmodule ElektrineWeb.LiteController do
end
end
def user(conn, %{"username" => username}) do
case Accounts.get_user_by_username_or_handle(username) do
%{banned: true} ->
not_found(conn)
%{suspended: true} = user ->
if Accounts.user_suspended?(user), do: not_found(conn), else: render_user(conn, user)
%{} = user ->
render_user(conn, user)
nil ->
not_found(conn)
end
end
def remote(conn, %{"handle" => handle}) do
case parse_remote_handle(handle) do
{:ok, %{username: username, domain: domain, acct: acct}} ->
@ -79,17 +200,155 @@ defmodule ElektrineWeb.LiteController do
nil ->
conn
|> put_flash(:error, "Actor not in local cache: #{acct}")
|> put_flash(:error, "Not found: #{acct}")
|> redirect(to: ~p"/lite/feed")
end
{:error, _} ->
conn
|> put_flash(:error, "Invalid remote handle.")
|> put_flash(:error, "Invalid handle.")
|> redirect(to: ~p"/lite/feed")
end
end
def compose(conn, _params) do
render(conn, :compose,
page_title: "New post",
content: "",
social_available?: social_available?()
)
end
def create_post(conn, %{"post" => post_params}) when is_map(post_params) do
user = conn.assigns.current_user
content = post_params |> Map.get("content", "") |> to_string() |> String.trim()
cond do
content == "" ->
conn
|> put_flash(:error, "Empty post.")
|> render(:compose,
page_title: "New post",
content: "",
social_available?: social_available?()
)
not social_available?() ->
conn
|> put_flash(:error, "Posting unavailable.")
|> redirect(to: ~p"/lite")
true ->
case create_timeline_post(user.id, content) do
{:ok, message} ->
conn
|> put_flash(:info, "Posted.")
|> redirect(to: ~p"/lite/post/#{message.id}")
{:error, :rate_limited} ->
conn
|> put_flash(:error, "Rate limited.")
|> render(:compose,
page_title: "New post",
content: content,
social_available?: true
)
{:error, _reason} ->
conn
|> put_flash(:error, "Post failed.")
|> render(:compose,
page_title: "New post",
content: content,
social_available?: true
)
end
end
end
def create_post(conn, _params) do
conn
|> put_flash(:error, "Invalid form.")
|> redirect(to: ~p"/lite/compose")
end
def account(conn, _params) do
render_account(conn)
end
def update_account(conn, %{"action" => action} = params) when is_binary(action) do
user = conn.assigns.current_user
case action do
"display_name" ->
update_lite_display_name(conn, user, params)
"password" ->
update_lite_password(conn, user, params)
"recovery_email" ->
update_lite_recovery_email(conn, user, params)
"revoke_other_sessions" ->
revoke_lite_other_sessions(conn, user)
_ ->
conn
|> put_flash(:error, "Unknown action.")
|> redirect(to: ~p"/lite/account")
end
end
def update_account(conn, _params) do
conn
|> put_flash(:error, "Invalid form.")
|> redirect(to: ~p"/lite/account")
end
def canary(conn, _params) do
canary = read_canary_file("current.md")
render(conn, :canary,
page_title: "Canary",
canary: canary,
canary_html: canary_to_html(canary),
signature: read_canary_file("current.md.asc"),
public_key: read_canary_file("canary-public-key.asc")
)
end
def prefer_lite(conn, _params) do
conn
|> put_resp_cookie(@lite_pref_cookie, "lite", lite_cookie_opts(conn))
|> redirect(to: ~p"/lite")
end
def prefer_full(conn, params) do
return_to = safe_return_to(params["return_to"] || "/")
conn
|> put_resp_cookie(@lite_pref_cookie, "full", lite_cookie_opts(conn))
|> redirect(to: return_to)
end
defp render_user(conn, user) do
posts = local_posts_for_user(user, 30)
label = user.handle || user.username
render(conn, :user,
page_title: "@#{label}",
profile_user: user,
posts: posts
)
end
defp not_found(conn) do
conn
|> put_status(404)
|> put_view(ElektrineWeb.ErrorHTML)
|> render(:"404")
end
defp put_lite_layouts(conn, _opts) do
conn
|> put_root_layout(html: {ElektrineWeb.Layouts, :lite_root})
@ -100,6 +359,371 @@ defmodule ElektrineWeb.LiteController do
put_session(conn, :user_return_to, "/lite")
end
defp require_lite_user(conn, _opts) do
if conn.assigns[:current_user] do
conn
else
conn
|> put_flash(:error, "Sign in required.")
|> redirect(to: ~p"/lite/login")
|> halt()
end
end
defp render_account(conn, opts \\ []) do
user = Keyword.get(opts, :user) || reload_lite_user(conn.assigns.current_user)
field_errors = Keyword.get(opts, :field_errors, %{})
current_session_id = get_session(conn, :user_session_id)
credit_balance =
if Code.ensure_loaded?(Atomine.Credits) do
Atomine.Credits.balance(user.id, :atomine_credit)
else
0
end
sessions =
user.id
|> Accounts.list_user_sessions()
|> Enum.filter(&is_nil(&1.revoked_at))
|> Enum.take(10)
render(conn, :account,
page_title: "Account",
user: user,
credit_balance: credit_balance,
sessions: sessions,
current_session_id: current_session_id,
field_errors: field_errors,
two_factor_enabled: user.two_factor_enabled == true
)
end
defp reload_lite_user(%User{id: id}), do: Accounts.get_user!(id)
defp reload_lite_user(user), do: user
defp update_lite_display_name(conn, user, params) do
display_name = params |> Map.get("display_name", "") |> to_string() |> String.trim()
case Accounts.update_user_display_name(user, display_name) do
{:ok, updated} ->
conn
|> put_flash(:info, "Display name saved.")
|> assign(:current_user, updated)
|> redirect(to: ~p"/lite/account")
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_flash(:error, "Could not save display name.")
|> render_account(user: user, field_errors: registration_errors(changeset))
end
end
defp update_lite_password(conn, user, params) do
attrs = %{
"current_password" => Map.get(params, "current_password", ""),
"password" => Map.get(params, "password", ""),
"password_confirmation" => Map.get(params, "password_confirmation", "")
}
case Accounts.update_user_password(user, attrs) do
{:ok, updated} ->
# Re-issue session so password_changed_at claims stay valid.
conn
|> put_session(:user_return_to, "/lite/account")
|> UserAuth.log_in_user(updated, %{},
flash: {:info, "Password updated."},
auth_method: :password
)
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_flash(:error, "Could not update password.")
|> render_account(user: user, field_errors: registration_errors(changeset))
end
end
defp update_lite_recovery_email(conn, user, params) do
current_password = Map.get(params, "current_password", "")
recovery_email = params |> Map.get("recovery_email", "") |> to_string() |> String.trim()
cond do
not Accounts.verify_user_password(user, current_password) ->
conn
|> put_flash(:error, "Current password is incorrect.")
|> render_account(
user: user,
field_errors: %{current_password: ["is incorrect"], recovery_email: []}
)
recovery_email == "" ->
conn
|> put_flash(:error, "Enter a recovery email.")
|> render_account(user: user, field_errors: %{recovery_email: ["can't be blank"]})
true ->
case RecoveryEmailVerification.set_recovery_email(user.id, recovery_email) do
{:ok, updated} ->
conn
|> put_flash(:info, "Recovery email saved. Check inbox to verify.")
|> assign(:current_user, updated)
|> redirect(to: ~p"/lite/account")
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_flash(:error, "Could not save recovery email.")
|> render_account(user: user, field_errors: registration_errors(changeset))
{:error, _} ->
conn
|> put_flash(:error, "Could not save recovery email.")
|> redirect(to: ~p"/lite/account")
end
end
end
defp revoke_lite_other_sessions(conn, user) do
current_session_id = get_session(conn, :user_session_id)
{count, _} =
Accounts.revoke_other_user_sessions(user.id, current_session_id, "lite_sign_out_others")
conn
|> put_flash(:info, "Signed out #{count} other session(s).")
|> redirect(to: ~p"/lite/account")
end
defp render_register_form(conn, user_params, invite_pref, changeset) do
invite_codes_enabled = Elektrine.System.invite_codes_enabled?()
ip = ClientIP.client_ip(conn)
errors =
if is_struct(changeset, Ecto.Changeset), do: registration_errors(changeset), else: %{}
case Captcha.generate(ip: ip) do
{image, _answer, token} ->
conn
|> put_session(:captcha_token, token)
|> render(:register,
page_title: "Register",
invite_codes_enabled: invite_codes_enabled,
username: Map.get(user_params, "username") || "",
invite_code: Map.get(user_params, "invite_code") || invite_pref || "",
errors: errors,
captcha_token: token,
captcha_src: "data:image/png;base64," <> Base.encode64(image)
)
{:error, :rate_limited} ->
conn
|> put_flash(:error, "Too many captcha requests. Wait and refresh.")
|> render(:register,
page_title: "Register",
invite_codes_enabled: invite_codes_enabled,
username: Map.get(user_params, "username") || "",
invite_code: Map.get(user_params, "invite_code") || invite_pref || "",
errors: errors,
captcha_token: "",
captcha_src: ""
)
end
end
defp finish_lite_registration(conn, user_params) do
result =
if Elektrine.System.invite_codes_enabled?() do
Accounts.register_user_with_access(user_params)
else
Accounts.create_user(user_params)
end
case result do
{:ok, user} ->
# Ensure post-login lands on lite (renew_session clears session keys).
conn
|> put_session(:user_return_to, "/lite")
|> UserAuth.log_in_user(user, %{}, flash: {:info, "Account created."})
{:error, %Ecto.Changeset{} = changeset} ->
render_register_form(conn, user_params, nil, changeset)
end
end
defp authenticate_lite_login(conn, username, password, user_params, ip) do
case Accounts.authenticate_user(username, password) do
{:ok, user} ->
RateLimiter.record_successful_attempt(ip)
RateLimiter.record_successful_attempt(username)
cond do
UserAuth.admin_login_restricted?(conn, user) ->
Events.auth(:password_login, :failure, %{reason: :admin_private_network_required})
conn
|> put_flash(:error, "Admin accounts must sign in over the private admin network.")
|> render(:login, page_title: "Sign in")
user.two_factor_enabled ->
Events.auth(:password_login, :challenge_required, %{reason: :two_factor_required})
conn
|> put_session(:user_return_to, "/lite")
|> UserAuth.store_user_for_two_factor_verification(user)
|> redirect(to: ~p"/two_factor")
true ->
Events.auth(:password_login, :success, %{reason: :password})
flash = UserAuth.login_flash_message(user, method: :password)
conn
|> put_session(:user_return_to, "/lite")
|> UserAuth.log_in_user(user, user_params,
flash: {:info, flash},
auth_method: :password
)
end
{:error, {:banned, reason}} ->
RateLimiter.record_failed_attempt(ip)
RateLimiter.record_failed_attempt(username)
Events.auth(:password_login, :failure, %{reason: :banned})
msg =
if Elektrine.Strings.present?(reason),
do: "Banned: #{reason}",
else: "Account banned."
conn
|> put_flash(:error, msg)
|> render(:login, page_title: "Sign in")
{:error, {:suspended, until, reason}} ->
RateLimiter.record_failed_attempt(ip)
RateLimiter.record_failed_attempt(username)
Events.auth(:password_login, :failure, %{reason: :suspended})
until_txt =
if until, do: Calendar.strftime(until, "%Y-%m-%d %H:%M UTC"), else: "further notice"
msg =
if Elektrine.Strings.present?(reason),
do: "Suspended until #{until_txt}: #{reason}",
else: "Suspended until #{until_txt}."
conn
|> put_flash(:error, msg)
|> render(:login, page_title: "Sign in")
{:error, :invalid_credentials} ->
RateLimiter.record_failed_attempt(ip)
RateLimiter.record_failed_attempt(username)
Events.auth(:password_login, :failure, %{reason: :invalid_credentials})
conn
|> put_flash(:error, "Invalid username or password.")
|> render(:login, page_title: "Sign in")
end
end
defp check_login_rate_limits(ip, username) do
case RateLimiter.check_rate_limit(ip) do
{:ok, :allowed} -> RateLimiter.check_rate_limit(username)
error -> error
end
end
defp verify_lite_captcha(conn, captcha_token, captcha_answer) do
token =
if is_binary(captcha_token) and captcha_token != "" do
captcha_token
else
get_session(conn, :captcha_token)
end
ip = ClientIP.client_ip(conn)
if is_nil(token) or is_nil(captcha_answer) or String.trim(to_string(captcha_answer)) == "" do
{:error, :missing_captcha}
else
Captcha.verify(token, to_string(captcha_answer), ip: ip)
end
end
defp captcha_error_message(:captcha_expired), do: "Captcha expired."
defp captcha_error_message(:wrong_answer), do: "Wrong captcha."
defp captcha_error_message(:missing_captcha), do: "Enter the captcha."
defp captcha_error_message(:invalid_token), do: "Captcha expired. Refresh."
defp captcha_error_message(:rate_limited), do: "Too many tries. Wait."
defp captcha_error_message(_), do: "Captcha failed."
defp registration_errors(%Ecto.Changeset{} = changeset) do
Enum.reduce(changeset.errors, %{}, fn {field, {message, opts}}, acc ->
msg =
Enum.reduce(opts, message, fn {key, value}, acc_msg ->
String.replace(acc_msg, "%{#{key}}", to_string(value))
end)
Map.update(acc, field, [msg], &(&1 ++ [msg]))
end)
end
defp registration_errors(_), do: %{}
# Same IPv6 /64 normalization as UserRegistrationController.
defp normalize_ipv6_subnet(ip_string) when is_binary(ip_string) do
if String.contains?(ip_string, ":") do
hextets = String.split(ip_string, ":")
if Enum.any?(hextets, &(&1 == "")) do
parts_before = Enum.take_while(hextets, &(&1 != ""))
parts_after = hextets |> Enum.drop_while(&(&1 != "")) |> Enum.drop(1)
zeros_needed = 8 - length(parts_before) - length(parts_after)
expanded = parts_before ++ List.duplicate("0", zeros_needed) ++ parts_after
expanded
|> Enum.take(4)
|> Enum.join(":")
|> Kernel.<>("::/64")
else
hextets
|> Enum.take(4)
|> Enum.join(":")
|> Kernel.<>("::/64")
end
else
ip_string
end
end
defp normalize_ipv6_subnet(_), do: ""
defp read_canary_file(filename) do
path =
:elektrine_web
|> :code.priv_dir()
|> Path.join("canary")
|> Path.join(filename)
case File.read(path) do
{:ok, content} -> content
{:error, _} -> nil
end
end
defp canary_to_html(nil), do: ""
defp canary_to_html(markdown) when is_binary(markdown) do
if Code.ensure_loaded?(Elektrine.Markdown) and
function_exported?(Elektrine.Markdown, :to_html, 1) do
Elektrine.Markdown.to_html(markdown)
else
markdown
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
|> then(&("<pre class=\"lite-body\">" <> &1 <> "</pre>"))
end
end
defp list_public_posts(limit, before_id) do
query =
from(m in Message,
@ -121,6 +745,18 @@ defmodule ElektrineWeb.LiteController do
Repo.all(query)
end
defp list_public_replies(parent_id, limit) do
from(m in Message,
where:
m.reply_to_id == ^parent_id and m.visibility == "public" and is_nil(m.deleted_at) and
fragment("? IS NOT TRUE", m.is_draft),
order_by: [asc: m.id],
limit: ^limit,
preload: [:sender, :remote_actor]
)
|> Repo.all()
end
defp fetch_public_post(id) do
case Integer.parse(to_string(id)) do
{int_id, ""} ->
@ -149,6 +785,38 @@ defmodule ElektrineWeb.LiteController do
|> Repo.all()
end
defp local_posts_for_user(user, limit) do
from(m in Message,
where:
m.sender_id == ^user.id and m.visibility == "public" and is_nil(m.deleted_at) and
is_nil(m.reply_to_id) and fragment("? IS NOT TRUE", m.is_draft),
order_by: [desc: m.id],
limit: ^limit,
preload: [:sender, :remote_actor]
)
|> Repo.all()
end
defp create_timeline_post(user_id, content) do
if social_available?() do
# optional platform module — only present when social is compiled
# credo:disable-for-next-line Credo.Check.Refactor.Apply
case Elektrine.Social.create_timeline_post(user_id, content, visibility: "public") do
{:ok, message} -> {:ok, message}
{:error, :rate_limited} -> {:error, :rate_limited}
{:error, {:rate_limited, _}} -> {:error, :rate_limited}
other -> other
end
else
{:error, :social_unavailable}
end
end
defp social_available? do
Code.ensure_loaded?(Elektrine.Social) and
function_exported?(Elektrine.Social, :create_timeline_post, 3)
end
defp parse_remote_handle(handle) when is_binary(handle) do
cleaned = String.trim(handle)
@ -208,4 +876,27 @@ defmodule ElektrineWeb.LiteController do
if String.ends_with?(host, ".onion"), do: host
end
defp lite_cookie_opts(conn) do
[
max_age: @lite_pref_max_age,
http_only: true,
same_site: "Lax",
path: "/",
# Match session cookies so Tor HTTP and clearnet HTTPS both stick.
secure: ElektrineWeb.SessionConfig.secure_cookies?(conn)
]
end
defp safe_return_to(path) when is_binary(path) do
case URI.parse(path) do
%URI{host: nil, scheme: nil, path: p} when is_binary(p) ->
if String.starts_with?(p, "/") and not String.starts_with?(p, "//"), do: p, else: "/"
_ ->
"/"
end
end
defp safe_return_to(_), do: "/"
end

View file

@ -19,6 +19,31 @@ defmodule ElektrineWeb.LiteHTML do
def author_label(%Message{remote_actor: %{username: u}}) when is_binary(u), do: "@#{u}"
def author_label(_), do: "unknown"
def author_link(%Message{sender: %{username: username} = sender}) when is_binary(username) do
slug = Map.get(sender, :handle) || username
assigns = %{slug: slug, label: "@#{username}"}
~H"""
<a href={"/lite/u/" <> URI.encode_www_form(@slug)}>{@label}</a>
"""
end
def author_link(%Message{remote_actor: actor} = message) when not is_nil(actor) do
assigns = %{path: remote_lite_path(actor), label: author_label(message)}
~H"""
<a href={@path}>{@label}</a>
"""
end
def author_link(message) do
assigns = %{label: author_label(message)}
~H"""
<span>{@label}</span>
"""
end
def post_body(%Message{} = message) do
message
|> Message.display_content()

View file

@ -0,0 +1,186 @@
<section class="lite-stack">
<h1 class="lite-h1">Account</h1>
<dl class="lite-card lite-stack-sm">
<div class="lite-row">
<dt class="lite-muted">Username</dt>
<dd>{@user.username}</dd>
</div>
<div class="lite-row">
<dt class="lite-muted">Handle</dt>
<dd>@{@user.handle || @user.username}</dd>
</div>
<div class="lite-row">
<dt class="lite-muted">Credits</dt>
<dd><strong>{@credit_balance}</strong></dd>
</div>
<div class="lite-row">
<dt class="lite-muted">2FA</dt>
<dd>
<%= if @two_factor_enabled do %>
On
<% else %>
Off
<% end %>
</dd>
</div>
</dl>
<div class="lite-actions">
<a href={~p"/lite/u/#{@user.handle || @user.username}"} class="lite-btn lite-btn-sm">
Profile
</a>
<a href={~p"/lite/email"} class="lite-btn lite-btn-sm">Mail</a>
<a href={~p"/lite/prefer-full?return_to=/account"} class="lite-btn lite-btn-sm">
Full settings
</a>
</div>
<section class="lite-stack-sm">
<h2 class="lite-h2">Display name</h2>
<form action={~p"/lite/account"} method="post" class="lite-form">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<input type="hidden" name="action" value="display_name" />
<label class="lite-label">
<span>Display name</span>
<input
type="text"
name="display_name"
value={@user.display_name || ""}
maxlength="50"
class="lite-input"
autocomplete="nickname"
/>
<%= for msg <- Map.get(@field_errors, :display_name, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
</label>
<button type="submit" class="lite-btn lite-btn-primary">Save</button>
</form>
</section>
<section class="lite-stack-sm">
<h2 class="lite-h2">Password</h2>
<form action={~p"/lite/account"} method="post" class="lite-form">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<input type="hidden" name="action" value="password" />
<label class="lite-label">
<span>Current password</span>
<input
type="password"
name="current_password"
required
autocomplete="current-password"
class="lite-input"
/>
<%= for msg <- Map.get(@field_errors, :current_password, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
</label>
<label class="lite-label">
<span>New password</span>
<input
type="password"
name="password"
required
minlength="8"
maxlength="72"
autocomplete="new-password"
class="lite-input"
/>
<%= for msg <- Map.get(@field_errors, :password, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
</label>
<label class="lite-label">
<span>Confirm new password</span>
<input
type="password"
name="password_confirmation"
required
minlength="8"
maxlength="72"
autocomplete="new-password"
class="lite-input"
/>
<%= for msg <- Map.get(@field_errors, :password_confirmation, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
</label>
<button type="submit" class="lite-btn lite-btn-primary">Update password</button>
</form>
</section>
<section class="lite-stack-sm">
<h2 class="lite-h2">Recovery email</h2>
<form action={~p"/lite/account"} method="post" class="lite-form">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<input type="hidden" name="action" value="recovery_email" />
<label class="lite-label">
<span>Email</span>
<input
type="email"
name="recovery_email"
value={@user.recovery_email || ""}
maxlength="160"
autocomplete="email"
class="lite-input"
required
/>
<%= for msg <- Map.get(@field_errors, :recovery_email, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
</label>
<%= if @user.recovery_email do %>
<p class="lite-xs lite-muted">
<%= if @user.recovery_email_verified do %>
Verified
<% else %>
Not verified — check your inbox
<% end %>
</p>
<% end %>
<label class="lite-label">
<span>Current password</span>
<input
type="password"
name="current_password"
required
autocomplete="current-password"
class="lite-input"
/>
</label>
<button type="submit" class="lite-btn lite-btn-primary">Save recovery email</button>
</form>
</section>
<section class="lite-stack-sm">
<h2 class="lite-h2">Sessions</h2>
<%= if @sessions == [] do %>
<p class="lite-muted lite-xs">No active sessions listed.</p>
<% else %>
<ul class="lite-list">
<%= for session <- @sessions do %>
<li class="lite-card lite-stack-sm">
<div class="lite-row">
<strong>{session.device_label || "Browser"}</strong>
<%= if session.id == @current_session_id do %>
<span class="lite-muted lite-xs">this device</span>
<% end %>
</div>
<p class="lite-xs lite-muted">
{[session.browser, session.platform, session.ip_address]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> Enum.join(" · ")}
</p>
</li>
<% end %>
</ul>
<% end %>
<form action={~p"/lite/account"} method="post" class="lite-form">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<input type="hidden" name="action" value="revoke_other_sessions" />
<button type="submit" class="lite-btn">Sign out other sessions</button>
</form>
</section>
</section>

View file

@ -0,0 +1,46 @@
<section class="lite-stack">
<header class="lite-row">
<h1 class="lite-h1">Canary</h1>
<div class="lite-actions">
<a href={~p"/canary/current.md"} class="lite-btn lite-btn-sm">current.md</a>
<a href={~p"/canary/current.md.asc"} class="lite-btn lite-btn-sm">.asc</a>
<a href={~p"/canary-key.asc"} class="lite-btn lite-btn-sm">key</a>
</div>
</header>
<section class="lite-stack-sm">
<h2 class="lite-h2">Statement</h2>
<div class="lite-card lite-prose">
<%= if @canary_html not in [nil, ""] do %>
{raw(@canary_html)}
<% else %>
No canary statement.
<% end %>
</div>
</section>
<section class="lite-stack-sm">
<h2 class="lite-h2">Verify</h2>
<pre class="lite-card lite-pre"><code>gpg --import canary-key.asc
gpg --verify current.md.asc current.md</code></pre>
<p class="lite-xs">
Missing or expired update = stale canary. That alone does not prove a specific demand.
</p>
</section>
<section class="lite-stack-sm">
<h2 class="lite-h2">Raw</h2>
<details class="lite-card lite-details">
<summary>Statement</summary>
<pre class="lite-pre"><%= @canary || "—" %></pre>
</details>
<details class="lite-card lite-details">
<summary>Signature</summary>
<pre class="lite-pre"><%= @signature || "—" %></pre>
</details>
<details class="lite-card lite-details">
<summary>Public key</summary>
<pre class="lite-pre"><%= @public_key || "—" %></pre>
</details>
</section>
</section>

View file

@ -0,0 +1,26 @@
<section class="lite-stack">
<p class="lite-muted">
<a href={~p"/lite/feed"}>← Feed</a>
</p>
<h1 class="lite-h1">New post</h1>
<%= if @social_available? do %>
<form action={~p"/lite/compose"} method="post" class="lite-form">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<label class="lite-label">
<span>Content</span>
<textarea
name="post[content]"
rows="8"
required
maxlength="10000"
class="lite-textarea"
>{@content}</textarea>
</label>
<button type="submit" class="lite-btn lite-btn-primary">Post</button>
</form>
<% else %>
<p class="lite-muted">Posting unavailable.</p>
<% end %>
</section>

View file

@ -1,49 +1,38 @@
<section class="space-y-4">
<header class="flex flex-wrap items-end justify-between gap-2">
<div>
<h1 class="text-xl font-semibold">Public feed</h1>
<p class="text-sm text-base-content/60">Federated public posts (read-only).</p>
</div>
<section class="lite-stack">
<header class="lite-row">
<h1 class="lite-h1">Feed</h1>
<%= if @current_user do %>
<a href={~p"/lite/compose"} class="lite-btn lite-btn-primary lite-btn-sm">New post</a>
<% end %>
</header>
<%= if @posts == [] do %>
<p class="text-sm text-base-content/60">No public posts to show.</p>
<p class="lite-muted">Nothing here.</p>
<% else %>
<ul class="divide-y divide-base-300 border border-base-300 rounded-lg">
<ul class="lite-list">
<%= for post <- @posts do %>
<li class="p-4 space-y-2">
<div class="flex flex-wrap items-baseline justify-between gap-2 text-sm">
<span class="font-medium">{author_label(post)}</span>
<a href={~p"/lite/post/#{post.id}"} class="link link-hover text-xs">
##{post.id}
</a>
<li class="lite-stack-sm">
<div class="lite-row">
<span class="lite-item-title">{author_link(post)}</span>
<a href={~p"/lite/post/#{post.id}"} class="lite-xs">##{post.id}</a>
</div>
<%= if post.title not in [nil, ""] do %>
<h2 class="text-base font-semibold">
<a href={~p"/lite/post/#{post.id}"} class="link link-hover">{post.title}</a>
</h2>
<% end %>
<p class="whitespace-pre-wrap text-sm leading-6 text-base-content/85">
{post_body(post)}
</p>
<%= if post.remote_actor do %>
<p class="text-xs">
<a href={remote_lite_path(post.remote_actor)} class="link link-hover">
View author (cached)
</a>
<p class="lite-item-title" style="margin:0">
<a href={~p"/lite/post/#{post.id}"}>{post.title}</a>
</p>
<% end %>
<p class="lite-body lite-body-pre">{post_body(post)}</p>
</li>
<% end %>
</ul>
<%= if @next_before do %>
<p class="pt-2">
<p class="lite-pager">
<a
href={~p"/lite/feed?before=#{@next_before}&limit=#{@limit}"}
class="btn btn-sm btn-outline"
class="lite-btn lite-btn-sm"
>
Older posts
Older
</a>
</p>
<% end %>

View file

@ -1,50 +1,65 @@
<section class="space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Privacy mode</h1>
<p class="mt-2 text-sm leading-6 text-base-content/70">
This surface is plain HTML and CSS. No JavaScript is served. Use it over Tor
or when you want fewer moving parts in the browser.
</p>
</div>
<section class="lite-stack">
<h1 class="lite-h1">Lite</h1>
<ul class="space-y-2 text-sm">
<ul class="lite-list">
<li>
<a href={~p"/lite/feed"} class="link link-primary">Public federated feed</a>
— read-only posts with link pagination
<a href={~p"/lite/feed"} class="lite-block">
<span class="lite-item-title">Feed</span>
</a>
</li>
<%= if @current_user do %>
<li>
<a href={~p"/lite/compose"} class="lite-block">
<span class="lite-item-title">Post</span>
</a>
</li>
<li>
<a href={~p"/lite/email"} class="lite-block">
<span class="lite-item-title">Mail</span>
</a>
</li>
<li>
<a href={~p"/lite/account"} class="lite-block">
<span class="lite-item-title">Account</span>
</a>
</li>
<% else %>
<li>
<a href={~p"/lite/login"} class="lite-block">
<span class="lite-item-title">Sign in</span>
</a>
</li>
<li>
<a href={~p"/lite/register"} class="lite-block">
<span class="lite-item-title">Register</span>
</a>
</li>
<li>
<a href={~p"/lite/email"} class="lite-block">
<span class="lite-item-title">Mail</span>
</a>
</li>
<% end %>
<li>
<a href={~p"/lite/canary"} class="lite-block">
<span class="lite-item-title">Canary</span>
</a>
</li>
<li>
<a href={~p"/lite/login"} class="link link-primary">Sign in</a>
— classic form (session cookie)
<a href={~p"/lite/prefer-full"} class="lite-block">
<span class="lite-item-title">Prefer full</span>
</a>
</li>
<li>
<a href={~p"/lite/email"} class="link link-primary">Mail</a>
— inbox, read, reply, compose, trash (requires sign-in)
</li>
<li>
<a href={~p"/canary"} class="link link-primary">Warrant canary</a>
<a href={~p"/lite/prefer-lite"} class="lite-block">
<span class="lite-item-title">Prefer lite</span>
</a>
</li>
</ul>
<%= if @via_tor do %>
<p class="rounded-lg border border-base-300 bg-base-200/40 px-3 py-2 text-sm">
You are on the onion service. Entry routes default here so the browser does
not load LiveView scripts.
</p>
<% end %>
<%= if @onion_host do %>
<p class="text-xs text-base-content/55 break-all">
Onion: <code>http://{@onion_host}/lite</code>
<p class="lite-xs lite-break">
<code>http://{@onion_host}/lite</code>
</p>
<% end %>
<div class="rounded-lg border border-base-300 p-4 text-sm leading-6 text-base-content/70">
<p class="font-medium text-base-content">Not available without JS</p>
<p class="mt-1">
Chat, live timeline, E2EE, Identity Credit earning (browser work / proofs),
rich mail, and most interactive tools stay on the full site. Lite mail is
plain-text read/send; external send still spends credits earned on the
full site.
</p>
</div>
</section>

View file

@ -1,44 +1,40 @@
<section class="mx-auto max-w-md space-y-4">
<h1 class="text-xl font-semibold">Sign in</h1>
<p class="text-sm text-base-content/65">
Classic form. After login you return to lite. Open the full site if you need
LiveView features.
</p>
<section class="lite-stack" style="max-width: 22rem">
<h1 class="lite-h1">Sign in</h1>
<form action={~p"/login"} method="post" class="space-y-3">
<form action={~p"/lite/login"} method="post" class="lite-form">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<label class="form-control w-full">
<span class="label-text text-sm">Username</span>
<label class="lite-label">
<span>Username</span>
<input
type="text"
name="user[username]"
required
autocomplete="username"
class="input input-bordered w-full"
class="lite-input"
/>
</label>
<label class="form-control w-full">
<span class="label-text text-sm">Password</span>
<label class="lite-label">
<span>Password</span>
<input
type="password"
name="user[password]"
required
autocomplete="current-password"
class="input input-bordered w-full"
class="lite-input"
/>
</label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="user[remember_me]" value="true" class="checkbox checkbox-sm" />
<label class="lite-check">
<input type="checkbox" name="user[remember_me]" value="true" />
Remember me
</label>
<button type="submit" class="btn btn-primary w-full">Sign in</button>
<button type="submit" class="lite-btn lite-btn-primary lite-btn-block">Sign in</button>
</form>
<p class="text-xs text-base-content/55">
2FA and passkeys still use the full login flow when required.
<p class="lite-xs">
<a href={~p"/lite/register"}>Register</a>
</p>
</section>

View file

@ -1,29 +1,44 @@
<article class="space-y-4">
<p class="text-sm">
<a href={~p"/lite/feed"} class="link link-hover">← Feed</a>
<article class="lite-stack">
<p class="lite-muted">
<a href={~p"/lite/feed"}>← Feed</a>
</p>
<header class="space-y-1">
<p class="text-sm font-medium">{author_label(@post)}</p>
<header class="lite-article-header lite-stack-sm">
<p class="lite-item-title" style="margin:0">{author_link(@post)}</p>
<%= if @post.title not in [nil, ""] do %>
<h1 class="text-2xl font-semibold tracking-tight">{@post.title}</h1>
<h1 class="lite-h1">{@post.title}</h1>
<% else %>
<h1 class="text-2xl font-semibold tracking-tight">Post #{@post.id}</h1>
<h1 class="lite-h1">Post #{@post.id}</h1>
<% end %>
<p class="text-xs text-base-content/55">
<p class="lite-xs">
{Calendar.strftime(@post.inserted_at, "%Y-%m-%d %H:%M UTC")}
</p>
</header>
<div class="whitespace-pre-wrap text-sm leading-7 text-base-content/90">
{post_body_full(@post)}
</div>
<div class="lite-body lite-body-pre">{post_body_full(@post)}</div>
<%= if @post.remote_actor do %>
<p class="text-sm">
<a href={remote_lite_path(@post.remote_actor)} class="link link-hover">
Author profile (lite)
</a>
</p>
<% end %>
<section class="lite-replies lite-stack">
<h2 class="lite-h2">Replies ({length(@replies)})</h2>
<%= if @replies == [] do %>
<p class="lite-muted">No replies.</p>
<% else %>
<ul class="lite-list">
<%= for reply <- @replies do %>
<li class="lite-stack-sm">
<div class="lite-row">
<span class="lite-item-title">{author_link(reply)}</span>
<span class="lite-xs">
{Calendar.strftime(reply.inserted_at, "%Y-%m-%d %H:%M")}
</span>
</div>
<p class="lite-body lite-body-pre">{post_body_full(reply)}</p>
<p class="lite-xs">
<a href={~p"/lite/post/#{reply.id}"}>Open ##{reply.id}</a>
</p>
</li>
<% end %>
</ul>
<% end %>
</section>
</article>

View file

@ -0,0 +1,122 @@
<section class="lite-stack" style="max-width: 22rem">
<h1 class="lite-h1">Register</h1>
<form action={~p"/lite/register"} method="post" class="lite-form">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<label class="lite-label">
<span>Username</span>
<input
type="text"
name="user[username]"
required
autocomplete="username"
value={@username}
class="lite-input"
minlength="2"
maxlength="30"
pattern="[A-Za-z0-9]+"
/>
<%= for msg <- Map.get(@errors, :username, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
</label>
<label class="lite-label">
<span>Password</span>
<input
type="password"
name="user[password]"
required
autocomplete="new-password"
class="lite-input"
minlength="8"
maxlength="72"
/>
<%= for msg <- Map.get(@errors, :password, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
</label>
<label class="lite-label">
<span>Confirm password</span>
<input
type="password"
name="user[password_confirmation]"
required
autocomplete="new-password"
class="lite-input"
minlength="8"
maxlength="72"
/>
<%= for msg <- Map.get(@errors, :password_confirmation, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
</label>
<%= if @invite_codes_enabled do %>
<label class="lite-label">
<span>Invite code</span>
<input
type="text"
name="user[invite_code]"
value={@invite_code}
class="lite-input"
autocomplete="off"
/>
<%= for msg <- Map.get(@errors, :invite_code, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
</label>
<% end %>
<label class="lite-check">
<input type="checkbox" name="user[agree_to_terms]" value="true" required />
<span>
I agree to the
<a href={~p"/terms"}>Terms</a>
&
<a href={~p"/privacy"}>Privacy Policy</a>
</span>
</label>
<%= for msg <- Map.get(@errors, :agree_to_terms, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
<div class="lite-stack-sm">
<span class="lite-muted">Type the characters</span>
<input type="hidden" name="captcha_token" value={@captcha_token} />
<div class="lite-actions">
<%= if @captcha_src != "" do %>
<img
src={@captcha_src}
alt="Captcha"
width="280"
height="90"
style="max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--radius); background: #fff"
/>
<% end %>
<a href={~p"/lite/register"} class="lite-btn lite-btn-sm">Refresh</a>
</div>
<input
type="text"
name="captcha_answer"
required
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class="lite-input"
placeholder="Characters from the image"
/>
<%= for msg <- Map.get(@errors, :captcha, []) do %>
<span class="lite-xs" style="color: var(--err-border)">{msg}</span>
<% end %>
</div>
<button type="submit" class="lite-btn lite-btn-primary lite-btn-block">Create account</button>
</form>
<p class="lite-xs">
<a href={~p"/lite/login"}>Sign in</a>
</p>
</section>

View file

@ -1,46 +1,32 @@
<section class="space-y-4">
<p class="text-sm">
<a href={~p"/lite/feed"} class="link link-hover">← Feed</a>
<section class="lite-stack">
<p class="lite-muted">
<a href={~p"/lite/feed"}>← Feed</a>
</p>
<header class="space-y-2">
<h1 class="text-2xl font-semibold tracking-tight">
<header class="lite-stack-sm">
<h1 class="lite-h1">
{@actor.display_name || @actor.username || @acct}
</h1>
<p class="text-sm text-base-content/65">{@acct}</p>
<p class="lite-muted">{@acct}</p>
<%= if is_binary(@actor.summary) and @actor.summary != "" do %>
<p class="whitespace-pre-wrap text-sm leading-6 text-base-content/80">
{String.slice(@actor.summary, 0, 800)}
</p>
<p class="lite-body">{String.slice(@actor.summary, 0, 800)}</p>
<% end %>
<p class="text-xs text-base-content/50">
Cached actors only (no live webfinger fetch in lite mode).
</p>
</header>
<h2 class="text-lg font-semibold">Recent public posts</h2>
<h2 class="lite-h2">Posts</h2>
<%= if @posts == [] do %>
<p class="text-sm text-base-content/60">No cached public posts for this actor.</p>
<p class="lite-muted">No posts.</p>
<% else %>
<ul class="divide-y divide-base-300 border border-base-300 rounded-lg">
<ul class="lite-list">
<%= for post <- @posts do %>
<li class="p-4 space-y-1">
<a href={~p"/lite/post/#{post.id}"} class="link link-hover font-medium text-sm">
<li class="lite-stack-sm">
<a href={~p"/lite/post/#{post.id}"} class="lite-item-title">
{post.title || "Post ##{post.id}"}
</a>
<p class="whitespace-pre-wrap text-sm text-base-content/80">{post_body(post)}</p>
<p class="lite-body lite-body-pre">{post_body(post)}</p>
</li>
<% end %>
</ul>
<% end %>
<p class="text-sm">
<a
href={"/remote/" <> URI.encode_www_form(String.trim_leading(@acct, "@"))}
class="link link-hover opacity-70"
>
Open full remote page
</a>
</p>
</section>

View file

@ -0,0 +1,34 @@
<section class="lite-stack">
<p class="lite-muted">
<a href={~p"/lite/feed"}>← Feed</a>
</p>
<header class="lite-stack-sm">
<h1 class="lite-h1">
{@profile_user.display_name || @profile_user.username}
</h1>
<p class="lite-muted">
@{@profile_user.handle || @profile_user.username}
</p>
<%= if is_binary(@profile_user.status_message) and @profile_user.status_message != "" do %>
<p class="lite-body">{String.slice(@profile_user.status_message, 0, 800)}</p>
<% end %>
</header>
<h2 class="lite-h2">Posts</h2>
<%= if @posts == [] do %>
<p class="lite-muted">No posts.</p>
<% else %>
<ul class="lite-list">
<%= for post <- @posts do %>
<li class="lite-stack-sm">
<a href={~p"/lite/post/#{post.id}"} class="lite-item-title">
{post.title || "Post ##{post.id}"}
</a>
<p class="lite-body lite-body-pre">{post_body(post)}</p>
</li>
<% end %>
</ul>
<% end %>
</section>

View file

@ -111,14 +111,14 @@ defmodule ElektrineWeb.PasswordResetController do
end
end
defp verify_password_reset_captcha(conn, params, password_reset_params, true, _ip_address) do
defp verify_password_reset_captcha(conn, params, password_reset_params, true, ip_address) do
captcha_token = get_session(conn, :captcha_token)
captcha_answer =
Map.get(password_reset_params, "captcha_answer") || Map.get(params, "captcha_answer")
if captcha_token && Elektrine.Strings.present?(captcha_answer) do
case Elektrine.Captcha.verify(captcha_token, captcha_answer) do
case Elektrine.Captcha.verify(captcha_token, captcha_answer, ip: ip_address) do
:ok -> {:ok, :verified}
error -> error
end
@ -203,7 +203,7 @@ defmodule ElektrineWeb.PasswordResetController do
end
end
defp missing_captcha_message(true), do: "Please solve the captcha."
defp missing_captcha_message(true), do: "Type the captcha characters."
defp missing_captcha_message(false), do: "Please complete the security check."
defp reset_confirmation_message do

View file

@ -50,7 +50,7 @@ defmodule ElektrineWeb.UserRegistrationController do
end
end
defp verify_required_captcha(conn, _captcha_token, captcha_answer, _remote_ip, true) do
defp verify_required_captcha(conn, _captcha_token, captcha_answer, remote_ip, true) do
require Logger
token = get_session(conn, :captcha_token)
@ -60,7 +60,7 @@ defmodule ElektrineWeb.UserRegistrationController do
)
if token && captcha_answer do
case Elektrine.Captcha.verify(token, captcha_answer) do
case Elektrine.Captcha.verify(token, captcha_answer, ip: remote_ip) do
:ok -> {:ok, :verified}
error -> error
end

View file

@ -52,7 +52,7 @@ defmodule ElektrineWeb.AuthLive.PasswordReset do
<%= if @via_tor do %>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Solve the problem")}</span>
<span class="label-text">{gettext("Type the characters")}</span>
</label>
<div class="flex items-center gap-3">
<img
@ -67,9 +67,11 @@ defmodule ElektrineWeb.AuthLive.PasswordReset do
<input
type="text"
name="password_reset[captcha_answer]"
placeholder={gettext("Your answer")}
placeholder={gettext("Characters from the image")}
required
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class="input input-bordered w-full mt-2"
/>
</div>

View file

@ -311,7 +311,7 @@ defmodule ElektrineWeb.AuthLive.Register do
<div class="w-full">
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Solve the problem")}</span>
<span class="label-text">{gettext("Type the characters")}</span>
</label>
<div class="flex items-center gap-3">
<img src={~p"/captcha"} alt="Captcha" class="rounded-lg border border-base-300" />
@ -322,9 +322,11 @@ defmodule ElektrineWeb.AuthLive.Register do
<input
type="text"
name="captcha_answer"
placeholder={gettext("Your answer")}
placeholder={gettext("Characters from the image")}
required
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
class="input input-bordered w-full mt-2"
/>
</div>

View file

@ -221,12 +221,12 @@ defmodule ElektrineWeb.PageLive.Home do
<span>Onion: not configured</span>
</span>
<.link
href={~p"/lite"}
href={~p"/lite/prefer-lite"}
class="inline-flex items-center gap-2 hover:text-white"
title="HTML-only privacy mode (no JavaScript)"
title="Prefer HTML-only lite (no JavaScript)"
>
<.icon name="hero-shield-check-mini" class="h-4 w-4" />
<span>Lite (no JS)</span>
<span>Prefer lite</span>
</.link>
</div>
</div>

View file

@ -1,34 +1,62 @@
defmodule ElektrineWeb.Plugs.PreferLiteOnOnion do
@moduledoc """
On Tor onion hosts, send the classic HTML `/lite` surface for entry routes.
Prefer the no-JS `/lite` surface for entry routes when:
1. The request is on a Tor onion host, or
2. The browser has the `elektrine_lite_pref=lite` cookie
Full LiveView pages still work if the user navigates to them explicitly
(e.g. `/timeline`), but the default onion landing does not load `app.js`.
(e.g. `/timeline`) or sets the preference cookie to `full`.
"""
import Plug.Conn
import Phoenix.Controller, only: [redirect: 2]
@entry_paths MapSet.new(["/", "/login"])
@pref_cookie "elektrine_lite_pref"
# Paths that should land on lite by default for Tor / lite-preferring clients.
@entry_redirects %{
"/" => "/lite",
"/login" => "/lite/login",
"/register" => "/lite/register",
"/timeline" => "/lite/feed",
"/email" => "/lite/email",
"/canary" => "/lite/canary"
}
def init(opts), do: opts
def call(conn, _opts) do
if onion?(conn) and MapSet.member?(@entry_paths, conn.request_path) do
target =
case conn.request_path do
"/login" -> "/lite/login"
_ -> "/lite"
end
conn = fetch_cookies(conn)
conn
|> put_status(302)
|> redirect(to: with_query(target, conn.query_string))
|> halt()
else
conn
cond do
# Never redirect POST/PUT/etc — that drops form bodies and breaks login/register.
conn.method not in ["GET", "HEAD"] ->
conn
prefer_full?(conn) ->
conn
Map.has_key?(@entry_redirects, conn.request_path) and prefer_lite?(conn) ->
target = Map.fetch!(@entry_redirects, conn.request_path)
conn
|> put_status(302)
|> redirect(to: with_query(target, conn.query_string))
|> halt()
true ->
conn
end
end
defp prefer_full?(conn) do
conn.cookies[@pref_cookie] == "full"
end
defp prefer_lite?(conn) do
onion?(conn) or conn.cookies[@pref_cookie] == "lite"
end
defp onion?(%Plug.Conn{assigns: %{via_tor: true}}), do: true
defp onion?(%Plug.Conn{host: host}) when is_binary(host), do: String.ends_with?(host, ".onion")
defp onion?(_), do: false

View file

@ -630,9 +630,20 @@ defmodule ElektrineWeb.Router do
# Privacy / no-JavaScript surface (also the default entry on .onion hosts).
get("/lite", LiteController, :home)
get("/lite/login", LiteController, :login)
post("/lite/login", LiteController, :create_login)
get("/lite/register", LiteController, :register)
post("/lite/register", LiteController, :create_registration)
get("/lite/feed", LiteController, :feed)
get("/lite/post/:id", LiteController, :post)
get("/lite/remote/:handle", LiteController, :remote)
get("/lite/u/:username", LiteController, :user)
get("/lite/compose", LiteController, :compose)
post("/lite/compose", LiteController, :create_post)
get("/lite/account", LiteController, :account)
post("/lite/account", LiteController, :update_account)
get("/lite/canary", LiteController, :canary)
get("/lite/prefer-lite", LiteController, :prefer_lite)
get("/lite/prefer-full", LiteController, :prefer_full)
scope "/", alias: false do
ElektrineWeb.Routes.Social.public_browser_routes()

View file

@ -101,6 +101,7 @@ defmodule ElektrineWeb.Routes.Email do
get("/lite/email/:id", ElektrineEmailWeb.LiteEmailController, :show)
get("/lite/email/:id/reply", ElektrineEmailWeb.LiteEmailController, :reply)
post("/lite/email/:id/trash", ElektrineEmailWeb.LiteEmailController, :trash)
post("/lite/email/:id/unread", ElektrineEmailWeb.LiteEmailController, :mark_unread)
end
end
end

View file

@ -150,10 +150,20 @@ defmodule ElektrineWeb.UserAuth do
This is a temporary session used during the 2FA verification process.
"""
def store_user_for_two_factor_verification(conn, user) do
conn
|> renew_session()
|> put_session(:two_factor_user_id, user.id)
|> put_session(:two_factor_timestamp, System.system_time(:second))
# renew_session clears the session; keep post-login destination (e.g. /lite).
user_return_to = get_session(conn, :user_return_to)
conn =
conn
|> renew_session()
|> put_session(:two_factor_user_id, user.id)
|> put_session(:two_factor_timestamp, System.system_time(:second))
if is_binary(user_return_to) and user_return_to != "" do
put_session(conn, :user_return_to, user_return_to)
else
conn
end
end
@doc """

View file

@ -59,8 +59,33 @@ async function parseJson(response) {
}
function errorMessage(payload, response) {
const code = payload?.error?.code
const message = payload?.error?.message || payload?.error || payload?.message
// Password changes and reconnects revoke the extension PAT — common user-facing failure.
if (
response.status === 401 ||
code === "invalid_token" ||
code === "token_revoked" ||
code === "token_expired" ||
code === "invalid_token_format" ||
code === "missing_token"
) {
if (code === "token_expired") {
return "Extension token expired. Open extension settings and reconnect to Elektrine."
}
if (code === "token_revoked" || code === "invalid_token") {
return "Extension token is invalid or was revoked (e.g. after a password change). Reconnect under extension settings."
}
if (typeof message === "string" && message.trim()) {
return `${message} Reconnect the extension under settings if this keeps failing.`
}
return "Extension is not authorized. Open settings and reconnect to Elektrine."
}
if (typeof message === "string" && message.trim()) {
return message
}
@ -83,7 +108,9 @@ async function request(settings, path, options = {}) {
if (options.auth !== false) {
if (!token) {
throw new Error("An access token is required.")
throw new Error(
"Not connected. Open extension settings and connect to Elektrine (token is missing after a reinstall)."
)
}
headers.Authorization = `Bearer ${token}`
@ -117,34 +144,6 @@ export function listEntries(settings) {
return request(settings, "/api/ext/v1/nerve/entries?limit=100")
}
// The server caps each page at 100 entries; page until a short page so vaults
// with more than 100 entries are not silently truncated.
export async function listAllEntries(settings) {
const limit = 100
let base = null
const entries = []
for (;;) {
const data = await request(
settings,
`/api/ext/v1/nerve/entries?limit=${limit}&offset=${entries.length}`
)
const pageEntries = Array.isArray(data.entries) ? data.entries : []
if (!base) {
base = data
}
entries.push(...pageEntries)
if (pageEntries.length < limit) {
break
}
}
return { ...base, entries }
}
export function getEntry(settings, entryId) {
return request(settings, `/api/ext/v1/nerve/entries/${entryId}`)
}
@ -158,20 +157,6 @@ export function createEntry(settings, attrs) {
})
}
export function setupVault(settings, attrs) {
return request(settings, "/api/ext/v1/nerve/vault/setup", {
method: "POST",
body: attrs
})
}
export function rotateVault(settings, attrs) {
return request(settings, "/api/ext/v1/nerve/vault/rotate", {
method: "POST",
body: attrs
})
}
export function updateEntry(settings, entryId, attrs) {
return request(settings, `/api/ext/v1/nerve/entries/${entryId}`, {
method: "PUT",
@ -230,6 +215,22 @@ export function isPersonalAccessToken(token) {
return ((token || "").trim()).startsWith("ekt_")
}
export function loginWithAccount(serverUrl, username, password, options = {}) {
return request(
{ serverUrl, apiToken: "" },
"/api/auth/login",
{
method: "POST",
auth: false,
body: {
username,
password,
...options
}
}
)
}
export function logoutWithAccount(settings) {
if (isPersonalAccessToken(settings.apiToken)) {
return Promise.resolve({ message: "Signed out locally." })

View file

@ -1,22 +1,14 @@
// serverUrl + apiToken live in chrome.storage.local so a browser restart does
// not force reconnect. Vault unlock passphrase stays session-only (memory /
// chrome.storage.session) and is cleared on restart / idle timeout.
const SETTINGS_KEYS = ["serverUrl", "apiToken", "theme"]
const TOKEN_KEY = "apiToken"
const THEME_KEY = "theme"
const PASSPHRASE_KEY = "vaultPassphrase"
const PREFS_KEY = "unlockPreferences"
const STAGED_FILLS_KEY = "stagedEntryFills"
const MASTER_CONFIGURED_KEY = "masterConfiguredForServer"
const ENCRYPTION_PROMPT_DISMISSED_KEY = "encryptionPromptDismissedAt"
const VAULT_CACHE_KEY = "vaultCache"
const PENDING_SAVE_MEMORY_TTL_MS = 5 * 60 * 1000
const pendingLoginSaves = new Map()
const DEFAULT_UNLOCK_PREFS = {
// Keep unlocked after the browser restarts (stores passphrase in local storage).
persistUnlock: false,
// Auto-lock after 15 minutes of idle while unlocked.
lockOnIdle: true
}
function getArea(areaName) {
const area = chrome?.storage?.[areaName]
@ -73,155 +65,63 @@ function storageRemove(areaName, keys) {
}
export async function getSettings() {
const values = await storageGet("local", SETTINGS_KEYS)
// Migrate older installs that kept the token only in session storage.
const sessionValues = await storageGet("session", [TOKEN_KEY, THEME_KEY]).catch(() => ({}))
const apiToken = values[TOKEN_KEY] || sessionValues[TOKEN_KEY] || ""
const theme = apiToken ? values[THEME_KEY] || sessionValues[THEME_KEY] || null : null
// Migrate tokens that were only in session storage (pre-fix: lost on restart).
await migrateSessionTokenToLocal()
if (apiToken && !values[TOKEN_KEY]) {
await storageSet("local", {
[TOKEN_KEY]: apiToken,
[THEME_KEY]: theme
})
}
const values = await storageGet("local", SETTINGS_KEYS)
const apiToken = (values[TOKEN_KEY] || "").trim()
return {
serverUrl: values.serverUrl || "",
apiToken,
theme
theme: apiToken ? values[THEME_KEY] || null : null
}
}
export async function saveSettings(settings) {
const apiToken = (settings.apiToken || "").trim()
const payload = {
const localValues = {
serverUrl: (settings.serverUrl || "").trim(),
[TOKEN_KEY]: apiToken
}
if (Object.prototype.hasOwnProperty.call(settings, "theme")) {
payload[THEME_KEY] = apiToken ? settings.theme || null : null
localValues[THEME_KEY] = settings.theme || null
}
await storageSet("local", payload)
await storageSet("local", localValues)
// Drop legacy session copies so one source of truth remains.
await storageRemove("session", [TOKEN_KEY, THEME_KEY]).catch(() => {})
}
// Drop legacy session copies so we do not re-read a stale session token later.
await storageRemove("session", [TOKEN_KEY, THEME_KEY])
export async function getUnlockPreferences() {
const values = await storageGet("local", [PREFS_KEY])
const stored = values[PREFS_KEY] || {}
return {
persistUnlock: Boolean(stored.persistUnlock),
lockOnIdle: stored.lockOnIdle === false ? false : true
if (!apiToken) {
await storageRemove("local", THEME_KEY)
}
}
export async function saveUnlockPreferences(prefs) {
const next = {
persistUnlock: Boolean(prefs.persistUnlock),
lockOnIdle: prefs.lockOnIdle === false ? false : true
async function migrateSessionTokenToLocal() {
try {
const local = await storageGet("local", [TOKEN_KEY, THEME_KEY, "serverUrl"])
if ((local[TOKEN_KEY] || "").trim()) return
const session = await storageGet("session", [TOKEN_KEY, THEME_KEY])
const sessionToken = (session[TOKEN_KEY] || "").trim()
if (!sessionToken) return
await storageSet("local", {
[TOKEN_KEY]: sessionToken,
[THEME_KEY]: session[THEME_KEY] || null
})
await storageRemove("session", [TOKEN_KEY, THEME_KEY])
} catch (_error) {
// Best-effort migration; getSettings still returns empty token if this fails.
}
await storageSet("local", { [PREFS_KEY]: next })
if (!next.persistUnlock) {
await clearPersistedPassphrase()
}
return next
}
export async function getPersistedPassphrase() {
const values = await storageGet("local", [PASSPHRASE_KEY])
return typeof values[PASSPHRASE_KEY] === "string" ? values[PASSPHRASE_KEY] : ""
}
export async function setPersistedPassphrase(passphrase) {
if (!passphrase) {
await clearPersistedPassphrase()
return
}
await storageSet("local", { [PASSPHRASE_KEY]: passphrase })
}
export async function clearPersistedPassphrase() {
await storageRemove("local", [PASSPHRASE_KEY])
}
// Legacy session-area passphrase key only. Persistent unlock uses local storage
// via clearPersistedPassphrase / setPersistedPassphrase.
export async function clearSessionPassphrase() {
await storageRemove("session", PASSPHRASE_KEY)
}
// The configured flag is cached per server so a stale value from a previous
// account or server cannot suppress the setup prompt for the current one.
export async function getCachedMasterConfigured(serverUrl) {
if (!serverUrl) {
return false
}
const values = await storageGet("local", [MASTER_CONFIGURED_KEY])
return values[MASTER_CONFIGURED_KEY] === serverUrl
}
export async function setCachedMasterConfigured(serverUrl, configured) {
if (configured && serverUrl) {
await storageSet("local", { [MASTER_CONFIGURED_KEY]: serverUrl })
} else {
await storageRemove("local", [MASTER_CONFIGURED_KEY])
}
}
// Local mirror of the server's entry list. Entries are stored exactly as the
// server returns them - ciphertext blobs plus the wrapped MDK - so caching them
// at rest grants nothing without the account password.
export async function getCachedVault(serverUrl) {
if (!serverUrl) {
return null
}
const values = await storageGet("local", [VAULT_CACHE_KEY])
const cache = values[VAULT_CACHE_KEY]
return cache && cache.serverUrl === serverUrl ? cache : null
}
export async function setCachedVault(serverUrl, vault) {
if (!serverUrl) {
return
}
await storageSet("local", {
[VAULT_CACHE_KEY]: {
serverUrl,
fetchedAt: Date.now(),
masterConfigured: Boolean(vault.masterConfigured),
masterWrappedDek: vault.masterWrappedDek || null,
theme: vault.theme || null,
entries: Array.isArray(vault.entries) ? vault.entries : []
}
})
}
export async function clearCachedVault() {
await storageRemove("local", [VAULT_CACHE_KEY])
}
export async function getEncryptionPromptDismissedAt() {
const values = await storageGet("local", [ENCRYPTION_PROMPT_DISMISSED_KEY])
return values[ENCRYPTION_PROMPT_DISMISSED_KEY] || 0
}
export async function setEncryptionPromptDismissedAt(timestamp) {
await storageSet("local", { [ENCRYPTION_PROMPT_DISMISSED_KEY]: timestamp })
}
function pendingSaveKey(tabId) {
return tabId ? String(tabId) : null
}

View file

@ -1,14 +1,22 @@
defmodule ElektrineWeb.LiteControllerTest do
use ElektrineWeb.ConnCase, async: true
use ElektrineWeb.ConnCase, async: false
alias Elektrine.Accounts
alias Elektrine.AccountsFixtures
alias Elektrine.Captcha
alias Elektrine.System, as: SystemSettings
describe "GET /lite" do
test "renders without LiveView javascript", %{conn: conn} do
test "renders without LiveView javascript and without full app.css", %{conn: conn} do
conn = get(conn, ~p"/lite")
html = html_response(conn, 200)
assert html =~ "Privacy mode"
assert html =~ "no JavaScript"
assert html =~ "Lite"
assert html =~ ~s(/assets/lite.css)
assert html =~ ~s(href="/lite/prefer-full")
assert html =~ ~s(href="/lite/prefer-lite")
refute html =~ ~s(/assets/app.js)
refute html =~ ~s(/assets/app.css)
refute html =~ "phx-track-static"
end
@ -16,19 +24,176 @@ defmodule ElektrineWeb.LiteControllerTest do
conn = get(conn, ~p"/lite/feed")
html = html_response(conn, 200)
assert html =~ "Public feed"
assert html =~ "Feed"
assert html =~ ~s(/assets/lite.css)
refute html =~ ~s(/assets/app.js)
end
test "login form posts to classic session endpoint", %{conn: conn} do
test "canary page is HTML-only", %{conn: conn} do
conn = get(conn, ~p"/lite/canary")
html = html_response(conn, 200)
assert html =~ "Canary"
assert html =~ ~s(/assets/lite.css)
refute html =~ ~s(/assets/app.js)
refute html =~ "e-nav-live"
end
test "login form posts to lite login", %{conn: conn} do
conn = get(conn, ~p"/lite/login")
html = html_response(conn, 200)
assert html =~ ~s(action="/login")
assert html =~ ~s(action="/lite/login")
assert html =~ ~s(name="user[username]")
assert html =~ ~s(name="user[password]")
assert html =~ ~s(href="/lite/register")
refute html =~ ~s(/assets/app.js)
end
test "register form embeds captcha (no separate /captcha race)", %{conn: conn} do
conn = get(conn, ~p"/lite/register")
html = html_response(conn, 200)
assert html =~ ~s(action="/lite/register")
assert html =~ ~s(name="user[username]")
assert html =~ ~s(name="user[password]")
assert html =~ ~s(name="captcha_answer")
assert html =~ ~s(name="captcha_token")
assert html =~ "data:image/png;base64,"
refute html =~ ~s(src="/captcha")
refute html =~ "can't be blank"
refute html =~ ~s(/assets/app.js)
end
test "lite login failure stays on lite login", %{conn: conn} do
conn =
post(conn, ~p"/lite/login", %{
"user" => %{"username" => "nope", "password" => "wrong-password"}
})
html = html_response(conn, 200)
assert html =~ "Invalid username or password"
assert html =~ ~s(action="/lite/login")
end
test "lite login success redirects to lite", %{conn: conn} do
password = AccountsFixtures.valid_user_password()
user = AccountsFixtures.user_fixture()
conn =
post(conn, ~p"/lite/login", %{
"user" => %{"username" => user.username, "password" => password}
})
assert redirected_to(conn) == "/lite"
assert get_session(conn, :user_token)
end
test "lite registration creates account and signs in", %{conn: conn} do
previous = SystemSettings.invite_codes_enabled?()
on_exit(fn -> SystemSettings.set_invite_codes_enabled(previous) end)
{:ok, _} = SystemSettings.set_invite_codes_enabled(false)
{_image, answer, token} = Captcha.generate()
username = "liteuser#{System.unique_integer([:positive])}"
conn =
post(conn, ~p"/lite/register", %{
"user" => %{
"username" => username,
"password" => "validpassword123",
"password_confirmation" => "validpassword123",
"agree_to_terms" => "true"
},
"captcha_token" => token,
"captcha_answer" => answer
})
assert redirected_to(conn) == "/lite"
assert get_session(conn, :user_token)
assert Accounts.get_user_by_username(username)
end
test "lite registration rejects wrong captcha and stays on form", %{conn: conn} do
previous = SystemSettings.invite_codes_enabled?()
on_exit(fn -> SystemSettings.set_invite_codes_enabled(previous) end)
{:ok, _} = SystemSettings.set_invite_codes_enabled(false)
{_image, _answer, token} = Captcha.generate()
conn =
post(conn, ~p"/lite/register", %{
"user" => %{
"username" => "litefail#{System.unique_integer([:positive])}",
"password" => "validpassword123",
"password_confirmation" => "validpassword123",
"agree_to_terms" => "true"
},
"captcha_token" => token,
"captcha_answer" => "WRONG1"
})
html = html_response(conn, 200)
assert html =~ "Wrong captcha"
assert html =~ ~s(action="/lite/register")
refute get_session(conn, :user_token)
end
test "compose requires authentication", %{conn: conn} do
conn = get(conn, ~p"/lite/compose")
assert redirected_to(conn) == "/lite/login"
end
test "account requires authentication", %{conn: conn} do
conn = get(conn, ~p"/lite/account")
assert redirected_to(conn) == "/lite/login"
end
end
describe "local profiles" do
test "renders a public local user profile", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn = get(conn, ~p"/lite/u/#{user.username}")
html = html_response(conn, 200)
assert html =~ user.username
assert html =~ "Posts"
refute html =~ ~s(/assets/app.js)
end
end
describe "lite preference cookies" do
test "prefer-lite sets cookie and redirects home", %{conn: conn} do
conn = get(conn, ~p"/lite/prefer-lite")
assert redirected_to(conn) == "/lite"
assert conn.resp_cookies["elektrine_lite_pref"].value == "lite"
end
test "prefer-full sets cookie and redirects", %{conn: conn} do
conn = get(conn, ~p"/lite/prefer-full?return_to=/timeline")
assert redirected_to(conn) == "/timeline"
assert conn.resp_cookies["elektrine_lite_pref"].value == "full"
end
test "clearnet home redirects to lite when preference cookie is lite", %{conn: conn} do
conn =
conn
|> put_req_cookie("elektrine_lite_pref", "lite")
|> get("/")
assert redirected_to(conn, 302) == "/lite"
end
test "full preference overrides onion entry redirect", %{conn: conn} do
conn =
conn
|> Map.put(:host, "example.onion")
|> put_req_cookie("elektrine_lite_pref", "full")
|> get("/")
refute conn.status == 302 and Phoenix.ConnTest.redirected_to(conn) == "/lite"
end
end
describe "GET /lite/email" do
@ -57,9 +222,129 @@ defmodule ElektrineWeb.LiteControllerTest do
assert redirected_to(conn, 302) == "/lite/login"
end
test "does not redirect clearnet home", %{conn: conn} do
test "does not redirect onion POST /login (preserves form body)", %{conn: conn} do
conn =
conn
|> Map.put(:host, "example.onion")
|> post("/login", %{"user" => %{"username" => "x", "password" => "y"}})
# PreferLiteOnOnion must not 302 POSTs; UserSessionController handles the body.
refute conn.status == 302 and redirected_to(conn) == "/lite/login"
end
test "redirects onion register to lite register", %{conn: conn} do
conn =
conn
|> Map.put(:host, "example.onion")
|> get("/register")
assert redirected_to(conn, 302) == "/lite/register"
end
test "redirects onion timeline to lite feed", %{conn: conn} do
conn =
conn
|> Map.put(:host, "example.onion")
|> get("/timeline")
assert redirected_to(conn, 302) == "/lite/feed"
end
test "redirects onion canary to lite canary", %{conn: conn} do
conn =
conn
|> Map.put(:host, "example.onion")
|> get("/canary")
assert redirected_to(conn, 302) == "/lite/canary"
end
test "does not redirect clearnet home without preference", %{conn: conn} do
conn = get(conn, "/")
refute conn.status == 302 and Phoenix.ConnTest.redirected_to(conn) == "/lite"
end
end
describe "authenticated lite pages" do
test "compose page for signed-in user", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn =
conn
|> log_in_user(user)
|> get(~p"/lite/compose")
html = html_response(conn, 200)
assert html =~ "New post"
assert html =~ ~s(name="post[content]")
end
test "account page shows credits and settings forms", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn =
conn
|> log_in_user(user)
|> get(~p"/lite/account")
html = html_response(conn, 200)
assert html =~ "Credits"
assert html =~ user.username
assert html =~ ~s(name="action" value="display_name")
assert html =~ ~s(name="action" value="password")
assert html =~ ~s(name="action" value="recovery_email")
assert html =~ ~s(name="action" value="revoke_other_sessions")
assert html =~ ~s(action="/lite/account")
end
test "updates display name from lite account", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn =
conn
|> log_in_user(user)
|> post(~p"/lite/account", %{
"action" => "display_name",
"display_name" => "Lite Name"
})
assert redirected_to(conn) == "/lite/account"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Display name"
assert Accounts.get_user!(user.id).display_name == "Lite Name"
end
test "changes password from lite account", %{conn: conn} do
password = AccountsFixtures.valid_user_password()
user = AccountsFixtures.user_fixture()
new_password = "new-secure-pass-99"
conn =
conn
|> log_in_user(user)
|> post(~p"/lite/account", %{
"action" => "password",
"current_password" => password,
"password" => new_password,
"password_confirmation" => new_password
})
assert redirected_to(conn) == "/lite/account"
assert get_session(conn, :user_token)
assert {:ok, _} = Accounts.authenticate_user(user.username, new_password)
end
end
defp log_in_user(conn, user) do
token =
Phoenix.Token.sign(ElektrineWeb.Endpoint, "user auth", %{
"user_id" => user.id,
"password_changed_at" =>
user.last_password_change && DateTime.to_unix(user.last_password_change),
"auth_valid_after" => user.auth_valid_after && DateTime.to_unix(user.auth_valid_after)
})
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
end
end

View file

@ -59,8 +59,33 @@ async function parseJson(response) {
}
function errorMessage(payload, response) {
const code = payload?.error?.code
const message = payload?.error?.message || payload?.error || payload?.message
// Password changes and reconnects revoke the extension PAT — common user-facing failure.
if (
response.status === 401 ||
code === "invalid_token" ||
code === "token_revoked" ||
code === "token_expired" ||
code === "invalid_token_format" ||
code === "missing_token"
) {
if (code === "token_expired") {
return "Extension token expired. Open extension settings and reconnect to Elektrine."
}
if (code === "token_revoked" || code === "invalid_token") {
return "Extension token is invalid or was revoked (e.g. after a password change). Reconnect under extension settings."
}
if (typeof message === "string" && message.trim()) {
return `${message} Reconnect the extension under settings if this keeps failing.`
}
return "Extension is not authorized. Open settings and reconnect to Elektrine."
}
if (typeof message === "string" && message.trim()) {
return message
}
@ -83,7 +108,9 @@ async function request(settings, path, options = {}) {
if (options.auth !== false) {
if (!token) {
throw new Error("An access token is required.")
throw new Error(
"Not connected. Open extension settings and connect to Elektrine (token is missing after a reinstall)."
)
}
headers.Authorization = `Bearer ${token}`

View file

@ -1,4 +1,7 @@
const SETTINGS_KEYS = ["serverUrl"]
// serverUrl + apiToken live in chrome.storage.local so a browser restart does
// not force reconnect. Vault unlock passphrase stays session-only (memory /
// chrome.storage.session) and is cleared on restart / idle timeout.
const SETTINGS_KEYS = ["serverUrl", "apiToken", "theme"]
const TOKEN_KEY = "apiToken"
const THEME_KEY = "theme"
const PASSPHRASE_KEY = "vaultPassphrase"
@ -62,33 +65,56 @@ function storageRemove(areaName, keys) {
}
export async function getSettings() {
// Migrate tokens that were only in session storage (pre-fix: lost on restart).
await migrateSessionTokenToLocal()
const values = await storageGet("local", SETTINGS_KEYS)
const tokenValues = await storageGet("session", [TOKEN_KEY, THEME_KEY])
const apiToken = tokenValues[TOKEN_KEY] || ""
const apiToken = (values[TOKEN_KEY] || "").trim()
return {
serverUrl: values.serverUrl || "",
apiToken,
theme: apiToken ? tokenValues[THEME_KEY] || null : null
theme: apiToken ? values[THEME_KEY] || null : null
}
}
export async function saveSettings(settings) {
await storageSet("local", {
serverUrl: (settings.serverUrl || "").trim()
})
const apiToken = (settings.apiToken || "").trim()
const sessionValues = { [TOKEN_KEY]: apiToken }
if (Object.prototype.hasOwnProperty.call(settings, "theme")) {
sessionValues[THEME_KEY] = settings.theme || null
const localValues = {
serverUrl: (settings.serverUrl || "").trim(),
[TOKEN_KEY]: apiToken
}
await storageSet("session", sessionValues)
if (Object.prototype.hasOwnProperty.call(settings, "theme")) {
localValues[THEME_KEY] = settings.theme || null
}
await storageSet("local", localValues)
// Drop legacy session copies so we do not re-read a stale session token later.
await storageRemove("session", [TOKEN_KEY, THEME_KEY])
if (!apiToken) {
await storageRemove("session", THEME_KEY)
await storageRemove("local", THEME_KEY)
}
}
async function migrateSessionTokenToLocal() {
try {
const local = await storageGet("local", [TOKEN_KEY, THEME_KEY, "serverUrl"])
if ((local[TOKEN_KEY] || "").trim()) return
const session = await storageGet("session", [TOKEN_KEY, THEME_KEY])
const sessionToken = (session[TOKEN_KEY] || "").trim()
if (!sessionToken) return
await storageSet("local", {
[TOKEN_KEY]: sessionToken,
[THEME_KEY]: session[THEME_KEY] || null
})
await storageRemove("session", [TOKEN_KEY, THEME_KEY])
} catch (_error) {
// Best-effort migration; getSettings still returns empty token if this fails.
}
}