defmodule OhMyExOhIrcd.IRC.ImgServ do
@moduledoc """
Pseudo-service that announces hosted images into channels.
Like `ScrobbleServ`, it's a Horde-supervised cluster-wide singleton
registered under `{:user, downcased_nick}`. It is *invited* into a
registered channel by the founder/op and joins as a voiced member (voice
keeps announcements flowing through `+m`). It's configured per-channel by
direct message (`/msg ImgServ HELP`) and via the web UI.
Images are uploaded out-of-band over an authenticated HTTP endpoint (see
`OhMyExOhIrcdWeb.ImageController`), which stores the file, then calls
`announce/4` with the public URL. The service checks the channel config
(enabled / allowlist / rate limit) and posts the rendered line.
Config, tokens, files, and history live in `OhMyExOhIrcd.Images`.
Rate-limit state is in-memory per `{channel, who}`.
"""
use GenServer, restart: :transient
require Logger
alias OhMyExOhIrcd.Cluster.{HordeRegistry, HordeSupervisor}
alias OhMyExOhIrcd.{Accounts, Channels, Images}
alias OhMyExOhIrcd.IRC.Channel
@join_access :voice
@announce_timeout 5_000
@max_line 400
## Public API
@doc "Configured ImgServ nick (default `ImgServ`)."
def nick do
Application.get_env(:oh_my_ex_oh_ircd, :services, [])
|> Keyword.get(:imgserv_nick, "ImgServ")
end
def host do
Application.get_env(:oh_my_ex_oh_ircd, :services, [])
|> Keyword.get(:service_host, "services.")
end
@doc "Ensure ImgServ is running somewhere in the cluster."
def ensure_started do
key = {:user, String.downcase(nick())}
case HordeRegistry.whereis(key) do
pid when is_pid(pid) ->
{:ok, pid}
nil ->
spec = %{
id: __MODULE__,
start: {__MODULE__, :start_link, []},
restart: :transient
}
case Horde.DynamicSupervisor.start_child(HordeSupervisor, spec) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
other -> other
end
end
end
def start_link(_opts \\ []) do
GenServer.start_link(__MODULE__, [],
name: HordeRegistry.via({:user, String.downcase(nick())})
)
end
@doc """
Announce a hosted image into `channel`. Returns `:ok` when posted,
`{:ignored, reason}` when suppressed (`:disabled`, `:not_allowed`,
`:rate_limited`), `{:error, :not_joined}` when not set up there, or
`{:error, :unavailable}` if the singleton can't be reached.
"""
def announce(channel, who, url, title \\ nil) do
case HordeRegistry.whereis({:user, String.downcase(nick())}) do
pid when is_pid(pid) ->
try do
GenServer.call(pid, {:announce, channel, who, url, title}, @announce_timeout)
catch
_, _ -> {:error, :unavailable}
end
nil ->
{:error, :unavailable}
end
end
## Server
@impl true
def init(_) do
Logger.info("ImgServ online as #{nick()}")
{:ok, %{rate: %{}}, {:continue, :rejoin}}
end
@impl true
def handle_continue(:rejoin, state) do
for channel <- safe_joined_channels() do
join_channel(channel)
end
{:noreply, state}
end
@impl true
def handle_call(:whois_info, _from, state) do
info = %{
nick: nick(),
user: "service",
host: host(),
realname: "Image Services",
channels: [],
idle: 0,
signon: 0,
away: nil,
account: nil
}
{:reply, info, state}
end
def handle_call({:announce, channel, who, url, title}, _from, state) do
case Images.get_config(channel) do
nil ->
{:reply, {:error, :not_joined}, state}
%{joined: false} ->
{:reply, {:error, :not_joined}, state}
%{enabled: false} ->
{:reply, {:ignored, :disabled}, state}
cfg ->
do_announce(state, channel, cfg, who, url, title)
end
end
## Invites / kicks
@impl true
def handle_info({:irc_invite, inviter, channel}, state) do
case require_op(channel, inviter) do
{:ok, _user} ->
case Images.ensure_config(channel) do
{:ok, _cfg} ->
Images.set_joined(channel, true)
join_channel(channel)
reply(inviter, [
"Joined #{channel}. Image announcements are ON.",
"Mint an upload token with: TOKEN NEW #{channel} <who>",
"See all commands with: HELP"
])
{:error, :not_registered} ->
reply(inviter, "#{channel} must be registered with ChanServ before I can join.")
{:error, _cs} ->
reply(inviter, "Could not set up image hosting on #{channel}.")
end
:stop ->
:ok
end
{:noreply, state}
end
def handle_info({:irc_kicked, channel}, state) do
Images.set_joined(channel, false)
{:noreply, %{state | rate: drop_channel_rate(state.rate, channel)}}
end
def handle_info({:irc_privmsg, %{sender: sender, text: text}}, state) do
sender_nick = extract_nick(sender)
Logger.debug("ImgServ <- #{sender_nick}: #{text}")
state = handle_command(text, sender_nick, state)
{:noreply, state}
end
def handle_info(_, state), do: {:noreply, state}
## Announce internals
defp do_announce(state, channel, cfg, who, url, title) do
who_norm = normalize_who(who)
cond do
cfg.restrict_to_allowlist and who_norm not in cfg.allowlist ->
{:reply, {:ignored, :not_allowed}, state}
rate_limited?(state.rate, channel, who_norm, cfg.rate_limit_seconds) ->
{:reply, {:ignored, :rate_limited}, state}
true ->
ensure_member(channel)
text = render(cfg.format, who, url, title)
Channel.privmsg(channel, service_prefix(), text)
{:reply, :ok, %{state | rate: mark_rate(state.rate, channel, who_norm)}}
end
end
defp render(format, who, url, title) do
format
|> String.replace("%who%", strip(who))
|> String.replace("%url%", strip(url))
|> String.replace("%title%", strip(title || ""))
|> String.trim()
|> clip(@max_line)
end
# Re-join if the Channel singleton restarted out from under us.
defp ensure_member(channel) do
case Channel.member_info(channel, nick()) do
%{access: _} -> :ok
_ -> join_channel(channel)
end
catch
_, _ -> :ok
end
defp join_channel(channel) do
Channel.join(channel, nick(), self(), nil, service_prefix())
Channel.service_set_access(channel, nick(), @join_access, nick())
:ok
catch
_, _ -> :ok
end
## Rate limiting (in-memory, per {downcased channel, downcased who})
defp rate_limited?(_rate, _channel, _who, seconds) when seconds <= 0, do: false
defp rate_limited?(rate, channel, who, seconds) do
case Map.get(rate, {downcase(channel), who}) do
nil -> false
last -> now() - last < seconds
end
end
defp mark_rate(rate, channel, who), do: Map.put(rate, {downcase(channel), who}, now())
defp drop_channel_rate(rate, channel) do
down = downcase(channel)
rate |> Enum.reject(fn {{c, _w}, _} -> c == down end) |> Map.new()
end
## Command dispatch
defp handle_command(text, who, state) do
case String.split(text, ~r/\s+/, trim: true) do
[] -> state
[cmd | args] -> do_command(String.upcase(cmd), args, who, state)
end
end
defp do_command("HELP", _args, who, state) do
reply(who, [
"ImgServ hosts images and announces their links into channels.",
"Invite me with /invite ImgServ #channel (founder/op of a registered channel).",
"Commands (all but HELP require you to be founder/op of the channel):",
" INFO #chan Show current configuration.",
" ENABLE / DISABLE #chan Turn announcements on/off.",
" FORMAT #chan <template> Set the line. Tokens: %who% %url% %title%.",
" RATELIMIT #chan <seconds> Min seconds between a who's posts (0 = off).",
" RESTRICT #chan ON|OFF Only announce allowlisted who-names.",
" ALLOW / DENY #chan <who> Add/remove a who-name from the allowlist.",
" ALLOWLIST #chan Show the allowlist.",
" TOKEN NEW #chan <who> [label] Mint an upload token bound to you (shown once).",
" TOKEN LIST #chan List tokens (who + label).",
" TOKEN REVOKE #chan <id> Revoke a token by id.",
" PART #chan Leave the channel and stop announcing."
])
state
end
defp do_command("INFO", [channel], who, state) do
with_op(channel, who, state, fn ->
case Images.get_config(channel) do
nil ->
reply(who, "#{channel} has no image config. Invite me first.")
cfg ->
retention =
if cfg.retention_days,
do: "#{cfg.retention_days}d (override)",
else: "#{Images.default_retention_days()}d (server default)"
reply(who, [
"#{channel} image config:",
" Joined: #{cfg.joined}",
" Enabled: #{cfg.enabled}",
" Format: #{cfg.format}",
" Ratelimit: #{cfg.rate_limit_seconds}s",
" Restrict: #{cfg.restrict_to_allowlist} (#{length(cfg.allowlist)} allowed)",
" Retention: #{retention}"
])
end
end)
end
defp do_command("ENABLE", [channel], who, state) do
with_op(channel, who, state, fn ->
Images.set_enabled(channel, true)
reply(who, "Announcements ENABLED on #{channel}.")
end)
end
defp do_command("DISABLE", [channel], who, state) do
with_op(channel, who, state, fn ->
Images.set_enabled(channel, false)
reply(who, "Announcements DISABLED on #{channel}.")
end)
end
defp do_command("FORMAT", [channel | rest], who, state) when rest != [] do
with_op(channel, who, state, fn ->
template = Enum.join(rest, " ")
case Images.set_format(channel, template) do
{:ok, cfg} -> reply(who, "Format for #{channel} set to: #{cfg.format}")
_ -> reply(who, "Could not set format.")
end
end)
end
defp do_command("RATELIMIT", [channel, seconds], who, state) do
with_op(channel, who, state, fn ->
case Integer.parse(seconds) do
{n, ""} when n >= 0 ->
case Images.set_rate_limit(channel, n) do
{:ok, _} -> reply(who, "Rate limit for #{channel} set to #{n}s.")
_ -> reply(who, "Could not set rate limit.")
end
_ ->
reply(who, "Usage: RATELIMIT #{channel} <seconds>")
end
end)
end
defp do_command("RESTRICT", [channel, toggle], who, state) do
with_op(channel, who, state, fn ->
case parse_toggle(toggle) do
{:ok, value} ->
Images.set_restrict(channel, value)
reply(who, "Allowlist enforcement on #{channel}: #{if value, do: "ON", else: "OFF"}.")
:error ->
reply(who, "Usage: RESTRICT #{channel} ON|OFF")
end
end)
end
defp do_command("ALLOW", [channel, target], who, state) do
with_op(channel, who, state, fn ->
Images.add_allow(channel, target)
reply(who, "Added #{target} to the #{channel} allowlist.")
end)
end
defp do_command("DENY", [channel, target], who, state) do
with_op(channel, who, state, fn ->
Images.remove_allow(channel, target)
reply(who, "Removed #{target} from the #{channel} allowlist.")
end)
end
defp do_command("ALLOWLIST", [channel], who, state) do
with_op(channel, who, state, fn ->
case Images.get_config(channel) do
%{allowlist: [_ | _] = list} ->
reply(who, "#{channel} allowlist: " <> Enum.join(list, ", "))
_ ->
reply(who, "#{channel} allowlist is empty.")
end
end)
end
defp do_command("TOKEN", ["NEW", channel, who_name | rest], who, state) do
with_op(channel, who, state, fn ->
label = if rest == [], do: nil, else: Enum.join(rest, " ")
case op_user(channel, who) do
nil ->
reply(who, "Could not mint a token.")
user ->
case Images.mint_token(channel, user, who_name, label) do
{:ok, display, _token} ->
reply(who, [
"New upload token for #{channel} as \"#{who_name}\" (shown once — store it now):",
" #{display}",
"POST an image as `Authorization: Bearer <token>` to /api/v1/image."
])
_ ->
reply(who, "Could not mint a token.")
end
end
end)
end
defp do_command("TOKEN", ["NEW" | _], who, state) do
reply(who, "Usage: TOKEN NEW #channel <who> [label]")
state
end
defp do_command("TOKEN", ["LIST", channel], who, state) do
with_op(channel, who, state, fn ->
case Images.list_tokens(channel) do
[] ->
reply(who, "#{channel} has no active tokens.")
tokens ->
reply(who, "Active tokens for #{channel}:")
Enum.each(tokens, &reply_token_line(who, &1))
end
end)
end
defp do_command("TOKEN", ["REVOKE", channel, id], who, state) do
with_op(channel, who, state, fn ->
case Integer.parse(id) do
{token_id, ""} ->
Images.revoke_token(channel, token_id)
reply(who, "Token ##{token_id} on #{channel} revoked (if it existed).")
_ ->
reply(who, "Usage: TOKEN REVOKE #{channel} <id>")
end
end)
end
defp do_command("TOKEN", _args, who, state) do
reply(who, "Usage: TOKEN NEW|LIST|REVOKE #channel [args]")
state
end
defp do_command("PART", [channel], who, state) do
case require_op(channel, who) do
{:ok, _user} ->
Images.set_joined(channel, false)
Channel.part(channel, nick(), "Image hosting disabled")
reply(who, "Left #{channel}. I'll stop announcing there.")
%{state | rate: drop_channel_rate(state.rate, channel)}
:stop ->
state
end
end
defp do_command(other, _args, who, state) do
reply(who, "Unknown command: #{other}. Try HELP.")
state
end
defp reply_token_line(who, t) do
used = if t.last_used_at, do: DateTime.to_iso8601(t.last_used_at), else: "never"
label = if t.label, do: " (#{t.label})", else: ""
reply(who, " ##{t.id} as #{t.who}#{label} — last used: #{used}")
end
## Authorization
defp with_op(channel, who, state, fun) do
case require_op(channel, who) do
{:ok, _user} -> fun.()
:stop -> :ok
end
state
end
defp require_op(channel, who) do
case Channels.get(channel) do
nil ->
reply(who, "#{channel} is not registered. Register it with ChanServ first.")
:stop
rec ->
with {:ok, user} <- require_account(who) do
if op?(user, who, channel, rec) do
{:ok, user}
else
reply(who, "Only the channel founder or an op may do that.")
:stop
end
end
end
end
defp require_account(who) do
case account_for(who) do
{:ok, user} ->
{:ok, user}
:error ->
reply(who, "You must be logged in. Connect with SASL or PASS using your account.")
:stop
end
end
defp op?(user, who, channel, rec) do
cond do
rec.founder_user_id == user.id -> true
persisted_op?(user, channel) -> true
live_op?(who, channel) -> true
true -> false
end
end
defp persisted_op?(user, channel) do
down = downcase(channel)
Channels.list_for_user(user)
|> Enum.any?(fn %{record: r, role: role} ->
r.name == down and role in [:founder, :admin, :op]
end)
end
defp live_op?(who, channel) do
case Channel.member_info(channel, who) do
%{access: a} -> Channel.rank(a) >= Channel.rank(:op)
_ -> false
end
catch
_, _ -> false
end
defp op_user(channel, who) do
case Channels.get(channel) do
nil ->
nil
_ ->
case account_for(who) do
{:ok, user} -> user
_ -> nil
end
end
end
defp account_for(who) do
case HordeRegistry.whereis({:user, String.downcase(who)}) do
pid when is_pid(pid) ->
try do
info = GenServer.call(pid, :whois_info, 2_000)
case info[:account] && Accounts.get_user_by_email(info.account) do
%_{} = user -> {:ok, user}
_ -> :error
end
catch
_, _ -> :error
end
nil ->
:error
end
end
## Reply helpers
defp reply(target_nick, lines) when is_list(lines),
do: Enum.each(lines, &reply(target_nick, &1))
defp reply(target_nick, line) when is_binary(line) do
case HordeRegistry.whereis({:user, String.downcase(target_nick)}) do
pid when is_pid(pid) ->
send(
pid,
{:irc_privmsg,
%{
sender: nick(),
target: target_nick,
text: line,
kind: :notice
}}
)
nil ->
:ok
end
end
## Misc
defp safe_joined_channels do
Images.list_joined_channels()
rescue
_ -> []
end
defp parse_toggle(t) do
case String.upcase(t) do
v when v in ["ON", "TRUE", "1", "YES"] -> {:ok, true}
v when v in ["OFF", "FALSE", "0", "NO"] -> {:ok, false}
_ -> :error
end
end
defp extract_nick(prefix) when is_binary(prefix) do
case String.split(prefix, "!", parts: 2) do
[n | _] -> n
end
end
defp normalize_who(who), do: who |> String.trim() |> String.downcase()
defp strip(s) when is_binary(s), do: String.replace(s, ~r/[\x00-\x1f]/, "")
defp strip(_), do: ""
defp clip(nil, _), do: nil
defp clip(s, max) when is_binary(s), do: String.slice(s, 0, max)
defp downcase(s) when is_binary(s), do: String.downcase(s)
defp now, do: System.system_time(:second)
defp service_prefix, do: "#{nick()}!service@#{host()}"
end
neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
17.5 KiB · text
History
4b6fe3b