defmodule OhMyExOhIrcd.Scrobbles do
@moduledoc """
Context for ScrobbleServ persistence: per-channel config, per-channel
API tokens, and the append-only scrobble history shown in the web UI.
All channel lookups go through `OhMyExOhIrcd.Channels.get/1`, so only
ChanServ-registered channels can be configured (the FK to `channels`
enforces this at the DB layer, and `on_delete: :delete_all` means
dropping a registration cascades away its config, tokens and history).
"""
import Ecto.Query, warn: false
alias OhMyExOhIrcd.Repo
alias OhMyExOhIrcd.Accounts.User
alias OhMyExOhIrcd.Channels
alias OhMyExOhIrcd.Channels.ChannelRecord
alias OhMyExOhIrcd.Scrobbles.{ChannelConfig, RequestLog, Scrobble, Token}
# How many request-log rows to keep per channel.
@log_retention 200
## Config
@doc "Fetch the config for a channel by name. Returns nil if absent."
def get_config(channel_name) when is_binary(channel_name) do
case Channels.get(channel_name) do
nil -> nil
%ChannelRecord{id: id} -> Repo.get_by(ChannelConfig, channel_id: id)
end
end
@doc """
Fetch the config for a registered channel, creating it (enabled) if it
doesn't exist yet. Returns `{:ok, config}` or `{:error, :not_registered}`.
"""
def ensure_config(channel_name, attrs \\ %{}) when is_binary(channel_name) do
case Channels.get(channel_name) do
nil ->
{:error, :not_registered}
%ChannelRecord{id: id} ->
case Repo.get_by(ChannelConfig, channel_id: id) do
nil ->
%ChannelConfig{}
|> ChannelConfig.changeset(Map.merge(%{channel_id: id}, attrs))
|> Repo.insert()
%ChannelConfig{} = cfg ->
{:ok, cfg}
end
end
end
@doc """
Update config fields for a registered channel, creating the row first if
needed. Returns `{:ok, config}`, `{:error, changeset}`, or
`{:error, :not_registered}`.
"""
def update_config(channel_name, attrs) when is_binary(channel_name) do
with {:ok, cfg} <- ensure_config(channel_name) do
cfg
|> ChannelConfig.changeset(attrs)
|> Repo.update()
end
end
def set_enabled(channel_name, value), do: update_config(channel_name, %{enabled: !!value})
def set_joined(channel_name, value), do: update_config(channel_name, %{joined: !!value})
def set_format(channel_name, format), do: update_config(channel_name, %{format: format})
def set_restrict(channel_name, value),
do: update_config(channel_name, %{restrict_to_allowlist: !!value})
def set_rate_limit(channel_name, seconds),
do: update_config(channel_name, %{rate_limit_seconds: seconds})
@doc "Add a `who` display name to the allowlist (stored downcased, deduped)."
def add_allow(channel_name, who) when is_binary(who) do
who = normalize_who(who)
with {:ok, cfg} <- ensure_config(channel_name) do
list = Enum.uniq([who | cfg.allowlist])
update_config(channel_name, %{allowlist: list})
end
end
@doc "Remove a `who` display name from the allowlist."
def remove_allow(channel_name, who) when is_binary(who) do
who = normalize_who(who)
with {:ok, cfg} <- ensure_config(channel_name) do
list = Enum.reject(cfg.allowlist, &(&1 == who))
update_config(channel_name, %{allowlist: list})
end
end
@doc "Channel names where ScrobbleServ should currently be present."
def list_joined_channels do
Repo.all(
from c in ChannelConfig,
where: c.joined == true,
join: ch in ChannelRecord,
on: ch.id == c.channel_id,
select: ch.name
)
end
## Tokens
@doc """
Mint a token for a registered channel, bound to `user` and announcing as
`who`. Returns `{:ok, display_token, token}` where `display_token` is
shown to the user once.
"""
def mint_token(channel_name, %User{} = user, who, label \\ nil)
when is_binary(channel_name) and is_binary(who) do
who = String.trim(who)
case Channels.get(channel_name) do
_ when who == "" ->
{:error, :blank_who}
nil ->
{:error, :not_registered}
%ChannelRecord{id: id} ->
{display, struct} = Token.build(id, user.id, who, label)
case struct |> Token.changeset(%{}) |> Repo.insert() do
{:ok, token} -> {:ok, display, token}
{:error, cs} -> {:error, cs}
end
end
end
@doc "List (non-revoked) tokens for a channel. Never exposes plaintext."
def list_tokens(channel_name) when is_binary(channel_name) do
case Channels.get(channel_name) do
nil ->
[]
%ChannelRecord{id: id} ->
Repo.all(
from t in Token,
where: t.channel_id == ^id and is_nil(t.revoked_at),
order_by: [desc: t.inserted_at]
)
end
end
@doc "Revoke a token by id, scoped to the channel. Idempotent."
def revoke_token(channel_name, token_id) when is_binary(channel_name) do
case Channels.get(channel_name) do
nil ->
:ok
%ChannelRecord{id: id} ->
from(t in Token,
where: t.id == ^token_id and t.channel_id == ^id and is_nil(t.revoked_at)
)
|> Repo.update_all(set: [revoked_at: DateTime.utc_now(:second)])
:ok
end
end
@doc """
Verify a presented bearer token. Returns `{:ok, %{channel:, who:,
user_id:}}` for a live (non-revoked) token — `who` is the bound display
name, NOT anything the client supplied — else `:error`. Bumps
`last_used_at` as a side effect on success.
"""
def verify_token(presented) when is_binary(presented) do
with {:ok, hash} <- Token.hash_presented(presented),
%Token{} = token <-
Repo.one(from t in Token, where: t.token_hash == ^hash and is_nil(t.revoked_at)),
%ChannelRecord{} = channel <- Repo.get(ChannelRecord, token.channel_id) do
Repo.update_all(
from(t in Token, where: t.id == ^token.id),
set: [last_used_at: DateTime.utc_now(:second)]
)
{:ok, %{channel: channel, who: token.who, user_id: token.user_id}}
else
_ -> :error
end
end
def verify_token(_), do: :error
## History
@doc "Persist an announced scrobble. `attrs` has who/artist/track/album."
def record_scrobble(channel_id, attrs) when is_integer(channel_id) do
%Scrobble{}
|> Scrobble.changeset(Map.put(attrs, :channel_id, channel_id))
|> Repo.insert()
end
@doc "Most recent `n` scrobbles for a channel, newest first."
def recent(channel_name, n \\ 25) when is_binary(channel_name) do
case Channels.get(channel_name) do
nil ->
[]
%ChannelRecord{id: id} ->
Repo.all(
from s in Scrobble,
where: s.channel_id == ^id,
order_by: [desc: s.inserted_at, desc: s.id],
limit: ^n
)
end
end
defp normalize_who(who), do: who |> String.trim() |> String.downcase()
## Request logs (debugging)
@doc """
Record the outcome of an authenticated scrobble request. `attrs` carries
`who`, `status` (e.g. "ok", "ignored", "not_joined", "invalid_payload",
"error") and an optional `detail`. Prunes to the most recent
`#{@log_retention}` rows per channel. Best-effort: never raises.
"""
def log_request(channel_id, attrs) when is_integer(channel_id) do
%RequestLog{}
|> RequestLog.changeset(Map.put(attrs, :channel_id, channel_id))
|> Repo.insert()
|> case do
{:ok, _} = ok ->
prune_logs(channel_id)
ok
other ->
other
end
rescue
_ -> :error
end
@doc "Most recent `n` request-log rows for a channel, newest first."
def recent_logs(channel_name, n \\ 50) when is_binary(channel_name) do
case Channels.get(channel_name) do
nil ->
[]
%ChannelRecord{id: id} ->
Repo.all(
from l in RequestLog,
where: l.channel_id == ^id,
order_by: [desc: l.inserted_at, desc: l.id],
limit: ^n
)
end
end
defp prune_logs(channel_id) do
keep_ids =
from(l in RequestLog,
where: l.channel_id == ^channel_id,
order_by: [desc: l.inserted_at, desc: l.id],
limit: @log_retention,
select: l.id
)
Repo.delete_all(
from l in RequestLog,
where: l.channel_id == ^channel_id and l.id not in subquery(keep_ids)
)
end
end
neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
8.2 KiB · text
History
4b6fe3b