Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
TELEGRAM_BOT_TOKEN=
TELEGRAM_BOT_USERNAME=
DEV_USER_ID=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ devenv.local.yaml
# pre-commit
.pre-commit-config.yaml
Session.vim
.env
2 changes: 2 additions & 0 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import MilkdownEditor from "./hooks/milkdown.js";
import { LayoutStickyToolbar } from "./hooks/layout-sticky-toolbar.js";
import Wikimap from "./hooks/wikimap.js";
import Wikitree from "./hooks/wikitree.js";
import { TelegramLoginHook } from "./hooks/telegram_login.js"

const csrfToken = document
.querySelector("meta[name='csrf-token']")
Expand All @@ -43,6 +44,7 @@ const liveSocket = new LiveSocket("/live", Socket, {
LayoutStickyToolbar,
Wikimap,
Wikitree,
TelegramLogin: TelegramLoginHook,
},
});

Expand Down
24 changes: 24 additions & 0 deletions assets/js/hooks/telegram_login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export const TelegramLoginHook = {
mounted() {
if (this.el.dataset.inited === "1") return
this.el.dataset.inited = "1"

const bot = this.el.dataset.botUsername
const req = this.el.dataset.requestAccess || "write"
const size = this.el.dataset.size || "large"

const { pathname, search } = window.location
const returnTo = `${pathname}${search || ""}` // <- path-only
const authUrl = `/auth/telegram/callback?return_to=${encodeURIComponent(returnTo)}`

const s = document.createElement("script")
s.async = true
s.src = "https://telegram.org/js/telegram-widget.js?22"
s.dataset.telegramLogin = bot
s.dataset.authUrl = authUrl
s.dataset.requestAccess = req
s.dataset.size = size

this.el.appendChild(s)
},
}
46 changes: 41 additions & 5 deletions lib/wik/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ defmodule Wik.Accounts.User do

defimpl String.Chars do
def to_string(user) do
[username, _] = Kernel.to_string(user.email) |> String.split("@", parts: 2)
username
Kernel.to_string(user.tg_username)
end
Comment on lines 9 to 12
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

to_string returns "" for email-only users.

Kernel.to_string(nil) returns "", so any user who signed in via magic link (without Telegram) will render as an empty string. This is a regression from the previous behavior that derived the display name from the email.

🐛 Proposed fix — fallback to email
  defimpl String.Chars do
    def to_string(user) do
-     Kernel.to_string(user.tg_username)
+     Kernel.to_string(user.tg_username || user.email || user.id)
    end
  end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
defimpl String.Chars do
def to_string(user) do
[username, _] = Kernel.to_string(user.email) |> String.split("@", parts: 2)
username
Kernel.to_string(user.tg_username)
end
defimpl String.Chars do
def to_string(user) do
Kernel.to_string(user.tg_username || user.email || user.id)
end
end
🤖 Prompt for AI Agents
In `@lib/wik/accounts/user.ex` around lines 9 - 12, The String.Chars
implementation for User (defimpl String.Chars in lib/wik/accounts/user.ex)
currently returns Kernel.to_string(user.tg_username) which yields "" for users
with no Telegram username; update def to fall back to the user's email
(user.email) when tg_username is nil/empty and ensure nil-safe conversion (e.g.,
prefer tg_username when present, otherwise use email, and finally a safe
Kernel.to_string on the chosen value) within the def to_string(user) function.

end

Expand Down Expand Up @@ -62,7 +61,7 @@ defmodule Wik.Accounts.User do

create :create do
primary? true
accept [:email]
accept [:email, :tg_id, :tg_first_name, :tg_last_name, :tg_username, :tg_photo_url]
end

create :sign_in_with_magic_link do
Expand All @@ -87,7 +86,7 @@ defmodule Wik.Accounts.User do

action :request_magic_link do
argument :email, :ci_string do
allow_nil? false
allow_nil? true
end

run AshAuthentication.Strategy.MagicLink.Request
Expand All @@ -111,13 +110,49 @@ defmodule Wik.Accounts.User do
)
)
end

policy action_type(:create) do
authorize_if always()
end
Comment on lines +113 to +116
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unrestricted :create policy allows unauthenticated user fabrication.

authorize_if always() on the :create action means any caller—not just the Telegram callback—can create arbitrary users with any tg_id/tg_username. An attacker could impersonate Telegram users by calling the Ash action directly without going through the OAuth flow. Consider scoping this bypass more narrowly (e.g., using a context check or a separate internal-only action for the Telegram upsert).

🤖 Prompt for AI Agents
In `@lib/wik/accounts/user.ex` around lines 113 - 116, The policy on
action_type(:create) currently uses authorize_if always(), allowing
unauthenticated creation; replace this broad bypass by either adding a
context-based check in the policy (e.g., require a specific context flag like
:telegram_callback_present or require actor presence) or split the behavior into
two actions and policies—keep the public create action authenticated/strict and
introduce an internal-only action (e.g., create_via_telegram or
upsert_from_telegram) with authorize_if that checks a secure context or internal
flag; update references to use the new internal action where the Telegram
callback performs upserts and remove authorize_if always() from
action_type(:create).

end

attributes do
uuid_primary_key :id

attribute :email, :ci_string do
allow_nil? false
allow_nil? true
public? true
end

attribute :confirmed_at, :utc_datetime_usec

attribute :role, :atom do
public? true
constraints one_of: [:user, :admin, :moderator]
default :user
end

attribute :tz, :string do
public? true
end

attribute :tg_id, :string do
public? true
end

attribute :tg_first_name, :string do
public? true
end

attribute :tg_last_name, :string do
public? true
end

attribute :tg_username, :string do
public? true
end

attribute :tg_photo_url, :string do
public? true
end
end
Expand All @@ -136,5 +171,6 @@ defmodule Wik.Accounts.User do

identities do
identity :unique_email, [:email]
identity :unique_tg_id, [:tg_id]
end
end
30 changes: 30 additions & 0 deletions lib/wik_web/components/telegram/widgets.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule WikWeb.Components.Telegram.Widgets do
use Phoenix.Component

# Optional attrs if you want to override defaults
attr :class, :string, default: nil
attr :request_access, :string, default: "write"
attr :size, :string, default: "large", values: ~w(small medium large)

def login(assigns) do
bot_username =
System.get_env("TELEGRAM_BOT_USERNAME") ||
raise "TELEGRAM_BOT_USERNAME not set"

assigns =
assigns
|> assign(:bot_username, bot_username)

~H"""
<div
id="telegram-login"
phx-hook="TelegramLogin"
phx-update="ignore"
class={@class}
data-bot-username={@bot_username}
data-request-access={@request_access}
data-size={@size}
/>
"""
end
end
36 changes: 36 additions & 0 deletions lib/wik_web/controllers/auth_controller/dev.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
defmodule WikWeb.AuthController.Dev do
use WikWeb, :controller
require Ash.Query
require Logger
alias AshAuthentication.Jwt
alias AshAuthentication.Plug.Helpers, as: AuthHelpers

def login(conn, _params) do
return_to = "/"

id = System.get_env("DEV_USER_ID", "000000000")

img =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcBAMAAACAI8KnAAABg2lDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw0AcxV9TpSKVDlYQcQhSneyioo61CkWoEGqFVh1Mrp/QpCFJcXEUXAsOfixWHVycdXVwFQTBDxB3wUnRRUr8X1JoEePBcT/e3XvcvQOERoWpZlcMUDXLSCXiYia7KgZeISCEAYxgRmamPidJSXiOr3v4+HoX5Vne5/4cfbm8yQCfSBxjumERbxBPb1o6533iMCvJOeJz4nGDLkj8yHXF5TfORYcFnhk20ql54jCxWOxgpYNZyVCJp4gjOVWjfCHjco7zFme1UmOte/IXBvPayjLXaQ4jgUUsQYIIBTWUUYGFKK0aKSZStB/38A85folcCrnKYORYQBUqZMcP/ge/uzULkxNuUjAOdL/Y9scoENgFmnXb/j627eYJ4H8GrrS2v9oAZj9Jr7e1yBEQ2gYurtuasgdc7gCDT7psyI7kpykUCsD7GX1TFui/BXrX3N5a+zh9ANLUVfIGODgExoqUve7x7p7O3v490+rvB903ctGnToVWAAAAIVBMVEVKHBxKHD5vKl68TqDKcrS8oE5OvLyFvE6eynK62Zv36fNZSEXTAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAAuIwAALiMBeKU/dgAAAAd0SU1FB+kCDxUqHL67UiAAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAZklEQVQY02OYiQDT0mYy4OROSw1NQ+e6IIBbmgsBrrGxCULAGJ3LYGzitWoVkOm1aolLBzbuQkEg11GKMLcDL7cc2d5yIrjOxiBg4l6OyS0vBjKVjJWUjc2xcSECxiAeMVwEQOMCAM+GiLYSTcf8AAAAAElFTkSuQmCC"

attrs = %{
tg_id: id,
tg_first_name: "Testuser",
tg_last_name: "Testuser",
tg_username: "Testuser",
tg_photo_url: img
}

db_user =
Wik.Accounts.User
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(upsert?: true, upsert_identity: :unique_tg_id)

{:ok, token, _claims} = Jwt.token_for_user(db_user)
db_user = Ash.Resource.set_metadata(db_user, %{token: token})

conn
|> AuthHelpers.store_in_session(db_user)
|> redirect(to: return_to)
end
end
97 changes: 97 additions & 0 deletions lib/wik_web/controllers/auth_controller/telegram.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
defmodule WikWeb.AuthController.Telegram do
use WikWeb, :controller
require Ash.Query
require Logger
alias AshAuthentication.Jwt
alias AshAuthentication.Plug.Helpers, as: AuthHelpers
alias Assent.Strategy.Telegram

defp config do
[
bot_token: System.get_env("TELEGRAM_BOT_TOKEN"),
# `, may be one of `:login_widget` or `:web_mini_app`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Comment fragment on line 12.

Line 12 has a stray comment fragment: `, may be one of `:login_widget` or `:web_mini_app`. Looks like a leftover from documentation. Clean it up.

🤖 Prompt for AI Agents
In `@lib/wik_web/controllers/auth_controller/telegram.ex` at line 12, Remove the
stray comment fragment "`, may be one of `:login_widget` or `:web_mini_app`"
from the Telegram auth controller's source (module/plug handling Telegram auth,
e.g., the Telegram auth controller module) — either delete the fragment or
complete the surrounding documentation/comment so it becomes a valid sentence,
ensuring no leftover backticks or partial text remain in the module's comments.

authorization_channel: :login_widget,
origin: ""
# return_to: "/"
# client_id: "REPLACE_WITH_CLIENT_ID",
# client_secret: "REPLACE_WITH_CLIENT_SECRET",
# redirect_uri: "http://localhost:4000/auth/telegram /callback"
]
end

def request(conn) do
config()
|> Telegram.authorize_url()
|> case do
{:ok, %{url: url, session_params: session_params}} ->
conn = put_session(conn, :session_params, session_params)

# Redirect end-user to Telegram to authorize access to their account
conn
|> put_resp_header("location", url)
|> send_resp(302, "")

{:error, error} ->
Logger.error("""
Error in AuthController.Telegram.authorize_url()
#{inspect(error)}
""")

nil
end
Comment on lines +34 to +41
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

request/1 returns nil on error — no response is sent to the client.

When Telegram.authorize_url() fails, the function returns nil instead of sending an HTTP response, which will cause Plug to raise a runtime error (Plug.Conn.NotSentError). You must send a proper error response.

🐛 Proposed fix
      {:error, error} ->
        Logger.error("""
          Error in AuthController.Telegram.authorize_url()
          #{inspect(error)}
        """)

-        nil
+        conn
+        |> put_flash(:error, "Failed to initiate Telegram login.")
+        |> redirect(to: "/sign-in")
    end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{:error, error} ->
Logger.error("""
Error in AuthController.Telegram.authorize_url()
#{inspect(error)}
""")
nil
end
{:error, error} ->
Logger.error("""
Error in AuthController.Telegram.authorize_url()
#{inspect(error)}
""")
conn
|> put_flash(:error, "Failed to initiate Telegram login.")
|> redirect(to: "/sign-in")
end
🤖 Prompt for AI Agents
In `@lib/wik_web/controllers/auth_controller/telegram.ex` around lines 34 - 41,
The error branch in Telegram.authorize_url() currently logs the error and
returns nil causing Plug.Conn.NotSentError; update the {:error, error} clause in
the request/1 function to send an HTTP error response (e.g., use
Plug.Conn.send_resp or put_status + json/text response) and return the conn —
keep the existing Logger.error call but follow it with a call like
send_resp(conn, 500, "authorization error") or a JSON error payload so the
connection is properly concluded and returned from request/1.

end

def callback(conn, params) do
return_to = params["return_to"]
params = params |> Map.delete("return_to")
Comment on lines +44 to +46
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Open redirect vulnerability via unvalidated return_to parameter.

params["return_to"] is user-controlled (passed as a query parameter in the callback URL) and used directly in redirect(to: return_to) on lines 82 and 94. An attacker can craft a callback URL like /auth/telegram/callback?return_to=https://evil.com to redirect authenticated users to a malicious site.

Validate that return_to is a relative path (starts with / and does not start with //).

🔒 Proposed fix
  def callback(conn, params) do
-   return_to = params["return_to"]
+   return_to = validated_return_to(params["return_to"])
    params = params |> Map.delete("return_to")

Add a private helper:

defp validated_return_to("/" <> _ = path) do
  case URI.parse(path) do
    %URI{host: nil} -> path
    _ -> "/"
  end
end

defp validated_return_to(_), do: "/"

Also applies to: 80-82, 92-94

🤖 Prompt for AI Agents
In `@lib/wik_web/controllers/auth_controller/telegram.ex` around lines 44 - 46,
The callback function uses an unvalidated user-controlled return_to from params
and passes it directly to redirect (seen in callback/redirect calls around the
callback function), causing an open-redirect; validate return_to before
redirecting by adding a helper (e.g., validated_return_to/1) that only accepts
relative paths that start with "/" but not "//" and whose URI.parse has host ==
nil, returning "/" for anything else, then replace direct use of
params["return_to"] in the callback function (and the redirect calls around
lines where redirect(to: return_to) is used) with
validated_return_to(return_to).

session_params = get_session(conn, :session_params)

config()
# Session params should be added to the config so the strategy can use them
|> Keyword.put(:session_params, session_params)
|> Telegram.callback(params)
|> case do
{:ok, %{user: user_from_telegram}} ->
attrs = %{
tg_id: user_from_telegram["sub"] |> to_string(),
tg_first_name: user_from_telegram["given_name"],
tg_last_name: user_from_telegram["family_name"],
tg_username: user_from_telegram["preferred_username"],
tg_photo_url: user_from_telegram["picture"]
}

db_user =
Wik.Accounts.User
|> Ash.Changeset.for_create(:create, attrs)
|> Ash.create!(upsert?: true, upsert_identity: :unique_tg_id)

{:ok, token, _claims} = Jwt.token_for_user(db_user)
db_user = Ash.Resource.set_metadata(db_user, %{token: token})

# current_user = %{
# id: db_user.id,
# first_name: db_user.telegram_first_name,
# last_name: db_user.telegram_last_name,
# username: db_user.telegram_username,
# photo_url: db_user.telegram_photo_url,
# role: db_user.role
# }

conn
|> AuthHelpers.store_in_session(db_user)
|> redirect(to: return_to)

{:error, error} ->
error_id = :rand.uniform(10_000)

Logger.error("""
Error #{error_id} in AuthController.Telegram.callback()
#{inspect(error)}
""")

conn
|> put_flash(:error, "Authentication failed. Error id: #{error_id}")
|> redirect(to: return_to)
end
end
end
27 changes: 27 additions & 0 deletions lib/wik_web/live/sign_in_live.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
defmodule WikWeb.SignInLive do
@moduledoc """
"""

use WikWeb, :live_view
alias WikWeb.Components.RealtimeToast
alias WikWeb.Components

@impl true
def render(assigns) do
~H"""
<div>
<Components.Telegram.Widgets.login />
<div :if={Mix.env() == :dev} class="text-center mt-4">
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Mix.env() is not available in production releases — this will crash at runtime.

Mix is a build tool and is not included in compiled releases. Calling Mix.env() in a LiveView template will raise UndefinedFunctionError in production. Use a compile-time module attribute or Application.compile_env/3 instead.

🐛 Proposed fix
 defmodule WikWeb.SignInLive do
+  `@dev_mode` Application.compile_env(:wik, :dev_routes, false)
+
   # ...
 
   def render(assigns) do
+    assigns = assign(assigns, :dev_mode, `@dev_mode`)
+
     ~H"""
     <div>
       <Components.Telegram.Widgets.login />
-      <div :if={Mix.env() == :dev} class="text-center mt-4">
+      <div :if={`@dev_mode`} class="text-center mt-4">
         <.link class="btn btn-primary" navigate="/dev/login">dev login</.link>
       </div>
     </div>
     """
   end
🤖 Prompt for AI Agents
In `@lib/wik_web/live/sign_in_live.ex` at line 14, The template uses Mix.env() (in
the LiveView markup) which is unavailable in releases; replace it with a
compile-time config check: add a module attribute (e.g. `@dev`) at the top of the
sign_in_live module that evaluates Application.compile_env/3 (or
Application.compile_env/2) to detect dev mode, then change the template :if from
Mix.env() == :dev to use that attribute (e.g. :if={`@dev`}); reference
Application.compile_env/3 and the `@dev` module attribute and the LiveView
template expression to locate where to change.

<.link class="btn btn-primary" navigate="/dev/login">dev login</.link>
</div>
</div>
"""
end

@impl true
def mount(_params, _session, socket) do
dbg("🔴")
dbg(socket.assigns)
{:ok, socket}
Comment on lines +22 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove dbg() calls before merging.

Lines 23–24 are debug artifacts that should not ship to production. dbg(socket.assigns) may also log sensitive session data (tokens, user info).

🐛 Proposed fix
   def mount(_params, _session, socket) do
-    dbg("🔴")
-    dbg(socket.assigns)
     {:ok, socket}
   end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def mount(_params, _session, socket) do
dbg("🔴")
dbg(socket.assigns)
{:ok, socket}
def mount(_params, _session, socket) do
{:ok, socket}
end
🤖 Prompt for AI Agents
In `@lib/wik_web/live/sign_in_live.ex` around lines 22 - 25, Remove the debug
calls left in mount/3: delete the dbg("🔴") and dbg(socket.assigns") invocations
in the mount function so you don't log sensitive data; update the function in
sign_in_live.ex (mount/3) to simply return {:ok, socket} without the dbg calls
and run tests/formatter to ensure no unused imports remain.

end
end
23 changes: 14 additions & 9 deletions lib/wik_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,19 @@ defmodule WikWeb.Router do
scope "/", WikWeb do
pipe_through :browser

get "/auth/telegram/callback", AuthController.Telegram, :callback
auth_routes AuthController, Wik.Accounts.User, path: "/auth"
sign_out_route AuthController

# Remove these if you'd like to use your own authentication views
sign_in_route register_path: "/register",
reset_path: "/reset",
auth_routes_prefix: "/auth",
on_mount: [{WikWeb.LiveUserAuth, :live_no_user}],
overrides: [
WikWeb.AuthOverrides,
Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI
]
# # Remove these if you'd like to use your own authentication views
# sign_in_route register_path: "/register",
# reset_path: "/reset",
# auth_routes_prefix: "/auth",
# on_mount: [{WikWeb.LiveUserAuth, :live_no_user}],
# overrides: [
# WikWeb.AuthOverrides,
# Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI
# ]

# Remove this if you do not want to use the reset password feature
reset_route auth_routes_prefix: "/auth",
Expand All @@ -64,6 +65,8 @@ defmodule WikWeb.Router do
auth_routes_prefix: "/auth",
overrides: [WikWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
)

live "/sign-in", SignInLive, :show
end

scope "/", WikWeb do
Expand Down Expand Up @@ -125,6 +128,8 @@ defmodule WikWeb.Router do
scope "/dev" do
pipe_through :browser

get "/login", WikWeb.AuthController.Dev, :login
live "/telegram", WikWeb.TelegramLive.BotUpdates, :index
live_dashboard "/dashboard", metrics: WikWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
Expand Down
Loading