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
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:
parent
0fd86bab7e
commit
b5a5b29fb0
42 changed files with 2492 additions and 724 deletions
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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." })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue