neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
loggs
5474077 · gmorell · 2026-06-11 18:42
Files changed
modified
lib/oh_my_ex_oh_ircd/scrobbles.ex
+59
−1
@@ -15,7 +15,10 @@ defmodule OhMyExOhIrcd.Scrobbles do
| 15 | 15 | alias OhMyExOhIrcd.Accounts.User |
| 16 | 16 | alias OhMyExOhIrcd.Channels |
| 17 | 17 | alias OhMyExOhIrcd.Channels.ChannelRecord |
| 18 | − alias OhMyExOhIrcd.Scrobbles.{ChannelConfig, Scrobble, Token} | |
| 18 | + alias OhMyExOhIrcd.Scrobbles.{ChannelConfig, RequestLog, Scrobble, Token} | |
| 19 | + | |
| 20 | + # How many request-log rows to keep per channel. | |
| 21 | + @log_retention 200 | |
| 19 | 22 | |
| 20 | 23 | ## Config |
| 21 | 24 |
@@ -212,4 +215,59 @@ defmodule OhMyExOhIrcd.Scrobbles do
| 212 | 215 | end |
| 213 | 216 | |
| 214 | 217 | defp normalize_who(who), do: who |> String.trim() |> String.downcase() |
| 218 | + | |
| 219 | + ## Request logs (debugging) | |
| 220 | + | |
| 221 | + @doc """ | |
| 222 | + Record the outcome of an authenticated scrobble request. `attrs` carries | |
| 223 | + `who`, `status` (e.g. "ok", "ignored", "not_joined", "invalid_payload", | |
| 224 | + "error") and an optional `detail`. Prunes to the most recent | |
| 225 | + `#{@log_retention}` rows per channel. Best-effort: never raises. | |
| 226 | + """ | |
| 227 | + def log_request(channel_id, attrs) when is_integer(channel_id) do | |
| 228 | + %RequestLog{} | |
| 229 | + |> RequestLog.changeset(Map.put(attrs, :channel_id, channel_id)) | |
| 230 | + |> Repo.insert() | |
| 231 | + |> case do | |
| 232 | + {:ok, _} = ok -> | |
| 233 | + prune_logs(channel_id) | |
| 234 | + ok | |
| 235 | + | |
| 236 | + other -> | |
| 237 | + other | |
| 238 | + end | |
| 239 | + rescue | |
| 240 | + _ -> :error | |
| 241 | + end | |
| 242 | + | |
| 243 | + @doc "Most recent `n` request-log rows for a channel, newest first." | |
| 244 | + def recent_logs(channel_name, n \\ 50) when is_binary(channel_name) do | |
| 245 | + case Channels.get(channel_name) do | |
| 246 | + nil -> | |
| 247 | + [] | |
| 248 | + | |
| 249 | + %ChannelRecord{id: id} -> | |
| 250 | + Repo.all( | |
| 251 | + from l in RequestLog, | |
| 252 | + where: l.channel_id == ^id, | |
| 253 | + order_by: [desc: l.inserted_at, desc: l.id], | |
| 254 | + limit: ^n | |
| 255 | + ) | |
| 256 | + end | |
| 257 | + end | |
| 258 | + | |
| 259 | + defp prune_logs(channel_id) do | |
| 260 | + keep_ids = | |
| 261 | + from(l in RequestLog, | |
| 262 | + where: l.channel_id == ^channel_id, | |
| 263 | + order_by: [desc: l.inserted_at, desc: l.id], | |
| 264 | + limit: @log_retention, | |
| 265 | + select: l.id | |
| 266 | + ) | |
| 267 | + | |
| 268 | + Repo.delete_all( | |
| 269 | + from l in RequestLog, | |
| 270 | + where: l.channel_id == ^channel_id and l.id not in subquery(keep_ids) | |
| 271 | + ) | |
| 272 | + end | |
| 215 | 273 | end |
added
lib/oh_my_ex_oh_ircd/scrobbles/request_log.ex
+32
−0
@@ -0,0 +1,32 @@
| 1 | +defmodule OhMyExOhIrcd.Scrobbles.RequestLog do | |
| 2 | + @moduledoc """ | |
| 3 | + Append-only log of authenticated scrobble API requests and their | |
| 4 | + outcome, for debugging from the channel's scrobble page. Only requests | |
| 5 | + that pass token auth are logged (we know the channel then); a request | |
| 6 | + with a bad URL or unknown/revoked token never reaches the controller, | |
| 7 | + so an empty log points at the URL or token rather than the channel. | |
| 8 | + """ | |
| 9 | + | |
| 10 | + use Ecto.Schema | |
| 11 | + import Ecto.Changeset | |
| 12 | + | |
| 13 | + alias OhMyExOhIrcd.Channels.ChannelRecord | |
| 14 | + | |
| 15 | + schema "scrobble_request_logs" do | |
| 16 | + field :who, :string | |
| 17 | + field :status, :string | |
| 18 | + field :detail, :string | |
| 19 | + | |
| 20 | + belongs_to :channel, ChannelRecord | |
| 21 | + | |
| 22 | + timestamps(type: :utc_datetime, updated_at: false) | |
| 23 | + end | |
| 24 | + | |
| 25 | + def changeset(record, attrs) do | |
| 26 | + record | |
| 27 | + |> cast(attrs, [:channel_id, :who, :status, :detail]) | |
| 28 | + |> validate_required([:channel_id, :status]) | |
| 29 | + |> validate_length(:who, max: 64) | |
| 30 | + |> validate_length(:detail, max: 256) | |
| 31 | + end | |
| 32 | +end |
modified
lib/oh_my_ex_oh_ircd_web/controllers/scrobble_controller.ex
+16
−0
@@ -9,6 +9,7 @@ defmodule OhMyExOhIrcdWeb.ScrobbleController do
| 9 | 9 | use OhMyExOhIrcdWeb, :controller |
| 10 | 10 | |
| 11 | 11 | alias OhMyExOhIrcd.IRC.ScrobbleServ |
| 12 | + alias OhMyExOhIrcd.Scrobbles | |
| 12 | 13 | |
| 13 | 14 | @required ~w(artist track) |
| 14 | 15 | @optional ~w(album) |
@@ -31,15 +32,30 @@ defmodule OhMyExOhIrcdWeb.ScrobbleController do
| 31 | 32 | fields["album"] |
| 32 | 33 | ) |
| 33 | 34 | |
| 35 | + log(channel, who, result) | |
| 34 | 36 | respond(conn, result) |
| 35 | 37 | |
| 36 | 38 | {:error, detail} -> |
| 39 | + log(channel, who, {:invalid_payload, detail}) | |
| 40 | + | |
| 37 | 41 | conn |
| 38 | 42 | |> put_status(:unprocessable_entity) |
| 39 | 43 | |> json(%{error: "invalid_payload", detail: detail}) |
| 40 | 44 | end |
| 41 | 45 | end |
| 42 | 46 | |
| 47 | + # Best-effort debug log of each authenticated request's outcome. | |
| 48 | + defp log(channel, who, outcome) do | |
| 49 | + {status, detail} = log_fields(outcome) | |
| 50 | + Scrobbles.log_request(channel.id, %{who: who, status: status, detail: detail}) | |
| 51 | + end | |
| 52 | + | |
| 53 | + defp log_fields(:ok), do: {"ok", nil} | |
| 54 | + defp log_fields({:ignored, reason}), do: {"ignored", to_string(reason)} | |
| 55 | + defp log_fields({:error, :not_joined}), do: {"not_joined", nil} | |
| 56 | + defp log_fields({:error, reason}), do: {"error", to_string(reason)} | |
| 57 | + defp log_fields({:invalid_payload, detail}), do: {"invalid_payload", detail} | |
| 58 | + | |
| 43 | 59 | defp respond(conn, :ok), do: json(conn, %{status: "ok"}) |
| 44 | 60 | |
| 45 | 61 | defp respond(conn, {:ignored, reason}), |
modified
lib/oh_my_ex_oh_ircd_web/live/user_live/scrobble.ex
+44
−1
@@ -11,6 +11,7 @@ defmodule OhMyExOhIrcdWeb.UserLive.Scrobble do
| 11 | 11 | alias OhMyExOhIrcd.Scrobbles |
| 12 | 12 | |
| 13 | 13 | @recent_limit 25 |
| 14 | + @log_limit 50 | |
| 14 | 15 | @manage_roles [:founder, :admin, :op] |
| 15 | 16 | |
| 16 | 17 | @impl true |
@@ -59,6 +60,7 @@ defmodule OhMyExOhIrcdWeb.UserLive.Scrobble do
| 59 | 60 | |> assign(:config_form, to_form(config_params(config), as: :config)) |
| 60 | 61 | |> assign(:tokens, Scrobbles.list_tokens(name)) |
| 61 | 62 | |> assign(:recent, Scrobbles.recent(name, @recent_limit)) |
| 63 | + |> assign(:logs, Scrobbles.recent_logs(name, @log_limit)) | |
| 62 | 64 | end |
| 63 | 65 | |
| 64 | 66 | defp config_params(nil), do: %{} |
@@ -275,7 +277,8 @@ defmodule OhMyExOhIrcdWeb.UserLive.Scrobble do
| 275 | 277 | Each token is bound to you and a fixed display name. POST scrobbles to |
| 276 | 278 | <code>/api/v1/scrobble</code> |
| 277 | 279 | with header <code>Authorization: Bearer <token></code> |
| 278 | − and a JSON body containing <code>artist</code> and <code>track</code> | |
| 280 | + and a JSON body containing <code>artist</code> | |
| 281 | + and <code>track</code> | |
| 279 | 282 | (optional <code>album</code>). |
| 280 | 283 | </p> |
| 281 | 284 | </section> |
@@ -306,11 +309,51 @@ defmodule OhMyExOhIrcdWeb.UserLive.Scrobble do
| 306 | 309 | </table> |
| 307 | 310 | </div> |
| 308 | 311 | </section> |
| 312 | + | |
| 313 | + <section class="space-y-3"> | |
| 314 | + <h2 class="font-mono text-lg">Request log</h2> | |
| 315 | + <p class="text-xs text-base-content/60"> | |
| 316 | + Authenticated API requests and their outcome. If this stays empty while a client is | |
| 317 | + posting, the request isn't reaching the endpoint or the token isn't valid — check the | |
| 318 | + URL (<code>/api/v1/scrobble</code>) and that the token hasn't been revoked. | |
| 319 | + </p> | |
| 320 | + <div class="overflow-x-auto rounded-box border border-base-300"> | |
| 321 | + <table class="table table-sm"> | |
| 322 | + <thead> | |
| 323 | + <tr> | |
| 324 | + <th>When</th> | |
| 325 | + <th>Who</th> | |
| 326 | + <th>Status</th> | |
| 327 | + <th>Detail</th> | |
| 328 | + </tr> | |
| 329 | + </thead> | |
| 330 | + <tbody> | |
| 331 | + <tr :for={l <- @logs}> | |
| 332 | + <td class="text-xs whitespace-nowrap">{format_ts(l.inserted_at)}</td> | |
| 333 | + <td class="font-mono">{l.who}</td> | |
| 334 | + <td> | |
| 335 | + <span class={"badge badge-sm " <> log_badge(l.status)}>{l.status}</span> | |
| 336 | + </td> | |
| 337 | + <td class="text-xs">{l.detail}</td> | |
| 338 | + </tr> | |
| 339 | + <tr :if={@logs == []}> | |
| 340 | + <td colspan="4" class="text-center text-base-content/60"> | |
| 341 | + No authenticated requests yet. | |
| 342 | + </td> | |
| 343 | + </tr> | |
| 344 | + </tbody> | |
| 345 | + </table> | |
| 346 | + </div> | |
| 347 | + </section> | |
| 309 | 348 | </div> |
| 310 | 349 | </Layouts.app> |
| 311 | 350 | """ |
| 312 | 351 | end |
| 313 | 352 | |
| 353 | + defp log_badge("ok"), do: "badge-success" | |
| 354 | + defp log_badge("ignored"), do: "badge-warning" | |
| 355 | + defp log_badge(_), do: "badge-error" | |
| 356 | + | |
| 314 | 357 | defp format_ts(nil), do: "never" |
| 315 | 358 | defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M") |
| 316 | 359 | end |
added
priv/repo/migrations/20260529000000_create_scrobble_request_logs.exs
+16
−0
@@ -0,0 +1,16 @@
| 1 | +defmodule OhMyExOhIrcd.Repo.Migrations.CreateScrobbleRequestLogs do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + def change do | |
| 5 | + create table(:scrobble_request_logs) do | |
| 6 | + add :channel_id, references(:channels, on_delete: :delete_all), null: false | |
| 7 | + add :who, :string | |
| 8 | + add :status, :string, null: false | |
| 9 | + add :detail, :string | |
| 10 | + | |
| 11 | + timestamps(type: :utc_datetime, updated_at: false) | |
| 12 | + end | |
| 13 | + | |
| 14 | + create index(:scrobble_request_logs, [:channel_id, :inserted_at]) | |
| 15 | + end | |
| 16 | +end |
modified
test/oh_my_ex_oh_ircd/scrobbles_test.exs
+21
−0
@@ -117,4 +117,25 @@ defmodule OhMyExOhIrcd.ScrobblesTest do
| 117 | 117 | assert hd(recent).track == "t5" |
| 118 | 118 | end |
| 119 | 119 | end |
| 120 | + | |
| 121 | + describe "request logs" do | |
| 122 | + test "log + recent newest-first", %{chan: chan} do | |
| 123 | + %{id: id} = Channels.get(chan) | |
| 124 | + {:ok, _} = Scrobbles.log_request(id, %{who: "gmp", status: "ok"}) | |
| 125 | + {:ok, _} = Scrobbles.log_request(id, %{who: "gmp", status: "ignored", detail: "rate_limited"}) | |
| 126 | + | |
| 127 | + logs = Scrobbles.recent_logs(chan, 10) | |
| 128 | + assert length(logs) == 2 | |
| 129 | + assert hd(logs).status == "ignored" | |
| 130 | + assert hd(logs).detail == "rate_limited" | |
| 131 | + end | |
| 132 | + | |
| 133 | + test "prunes beyond retention", %{chan: chan} do | |
| 134 | + %{id: id} = Channels.get(chan) | |
| 135 | + for n <- 1..205, do: {:ok, _} = Scrobbles.log_request(id, %{who: "w", status: "ok", detail: "#{n}"}) | |
| 136 | + | |
| 137 | + # Retention caps stored rows at 200. | |
| 138 | + assert length(Scrobbles.recent_logs(chan, 1000)) == 200 | |
| 139 | + end | |
| 140 | + end | |
| 120 | 141 | end |
modified
test/oh_my_ex_oh_ircd_web/controllers/scrobble_controller_test.exs
+17
−3
@@ -43,11 +43,17 @@ defmodule OhMyExOhIrcdWeb.ScrobbleControllerTest do
| 43 | 43 | assert body["error"] == "invalid_payload" |
| 44 | 44 | end |
| 45 | 45 | |
| 46 | − test "ignores a client-supplied who (who is token-bound)", %{conn: conn, token: token, chan: chan} do | |
| 46 | + test "ignores a client-supplied who (who is token-bound)", %{ | |
| 47 | + conn: conn, | |
| 48 | + token: token, | |
| 49 | + chan: chan | |
| 50 | + } do | |
| 47 | 51 | {:ok, _} = Scrobbles.ensure_config(chan) |
| 48 | 52 | {:ok, _} = Scrobbles.set_joined(chan, true) |
| 49 | 53 | |
| 50 | − conn = conn |> auth(token) |> post(~p"/api/v1/scrobble", Map.put(payload(), "who", "someone-else")) | |
| 54 | + conn = | |
| 55 | + conn |> auth(token) |> post(~p"/api/v1/scrobble", Map.put(payload(), "who", "someone-else")) | |
| 56 | + | |
| 51 | 57 | assert json_response(conn, 200) == %{"status" => "ok"} |
| 52 | 58 | |
| 53 | 59 | assert [scrobble] = Scrobbles.recent(chan, 5) |
@@ -67,10 +73,18 @@ defmodule OhMyExOhIrcdWeb.ScrobbleControllerTest do
| 67 | 73 | assert [_scrobble] = Scrobbles.recent(chan, 5) |
| 68 | 74 | end |
| 69 | 75 | |
| 70 | − test "valid token but ScrobbleServ not joined returns 404", %{conn: conn, token: token} do | |
| 76 | + test "valid token but ScrobbleServ not joined returns 404 and is logged", %{ | |
| 77 | + conn: conn, | |
| 78 | + token: token, | |
| 79 | + chan: chan | |
| 80 | + } do | |
| 71 | 81 | # No invite happened → config (if any) is joined: false. |
| 72 | 82 | conn = conn |> auth(token) |> post(~p"/api/v1/scrobble", payload()) |
| 73 | 83 | assert json_response(conn, 404) == %{"error" => "not_joined"} |
| 84 | + | |
| 85 | + assert [log] = Scrobbles.recent_logs(chan, 5) | |
| 86 | + assert log.status == "not_joined" | |
| 87 | + assert log.who == "gmp" | |
| 74 | 88 | end |
| 75 | 89 | |
| 76 | 90 | test "joined + enabled channel announces and records", %{conn: conn, token: token, chan: chan} do |
Parents: da770be