feat: web UI — dashboard, indexed browser, publisher manager, publish queue
This commit is contained in:
@@ -717,6 +717,204 @@ pub async fn get_identity(pool: &SqlitePool) -> anyhow::Result<Option<IdentityRo
|
||||
Ok(row.map(|(pubkey, nsec, bunker_url)| IdentityRow { pubkey, nsec, bunker_url }))
|
||||
}
|
||||
|
||||
// ─── UI helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct UiStats {
|
||||
pub torrent_count: i64,
|
||||
pub publisher_count: i64,
|
||||
pub queued_count: i64,
|
||||
pub published_count: i64,
|
||||
pub last_event_at: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn ui_stats(pool: &SqlitePool) -> anyhow::Result<UiStats> {
|
||||
let (torrent_count, last_event_at): (i64, Option<i64>) =
|
||||
sqlx::query_as("SELECT COUNT(*), MAX(ingested_at) FROM torrents")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let (publisher_count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM publishers")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let (queued_count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM publish_queue WHERE published_at IS NULL AND error IS NULL")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
let (published_count,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM publish_queue WHERE published_at IS NOT NULL")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap_or((0,));
|
||||
Ok(UiStats { torrent_count, publisher_count, queued_count, published_count, last_event_at })
|
||||
}
|
||||
|
||||
pub struct UiRelayRow {
|
||||
pub url: String,
|
||||
pub last_event: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn list_relays_ui(pool: &SqlitePool) -> anyhow::Result<Vec<UiRelayRow>> {
|
||||
let rows: Vec<(String, Option<i64>)> =
|
||||
sqlx::query_as("SELECT url, last_event FROM relays WHERE enabled = 1 ORDER BY url")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(url, last_event)| UiRelayRow { url, last_event }).collect())
|
||||
}
|
||||
|
||||
pub struct RecentTorrentRow {
|
||||
pub title: String,
|
||||
pub category: String,
|
||||
pub pubkey: String,
|
||||
pub ingested_at: i64,
|
||||
}
|
||||
|
||||
pub async fn recent_torrents(pool: &SqlitePool, limit: i64) -> anyhow::Result<Vec<RecentTorrentRow>> {
|
||||
let rows: Vec<(String, String, String, i64)> = sqlx::query_as(
|
||||
"SELECT title, COALESCE(category,''), pubkey, ingested_at
|
||||
FROM torrents ORDER BY ingested_at DESC LIMIT ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(title, category, pubkey, ingested_at)| RecentTorrentRow {
|
||||
title, category, pubkey, ingested_at,
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub struct BrowseTorrentRow {
|
||||
pub event_id: String,
|
||||
pub info_hash: String,
|
||||
pub title: String,
|
||||
pub category: String,
|
||||
pub size_bytes: Option<i64>,
|
||||
pub pubkey: String,
|
||||
pub ingested_at: i64,
|
||||
}
|
||||
|
||||
impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for BrowseTorrentRow {
|
||||
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
|
||||
use sqlx::Row;
|
||||
Ok(BrowseTorrentRow {
|
||||
event_id: row.try_get("event_id")?,
|
||||
info_hash: row.try_get("info_hash")?,
|
||||
title: row.try_get("title")?,
|
||||
category: row.try_get("category").unwrap_or_default(),
|
||||
size_bytes: row.try_get("size_bytes")?,
|
||||
pubkey: row.try_get("pubkey")?,
|
||||
ingested_at: row.try_get("ingested_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn browse_torrents(
|
||||
pool: &SqlitePool,
|
||||
q: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> anyhow::Result<Vec<BrowseTorrentRow>> {
|
||||
if q.is_empty() {
|
||||
sqlx::query_as(
|
||||
"SELECT event_id, info_hash, title, COALESCE(category,'') AS category,
|
||||
size_bytes, pubkey, ingested_at
|
||||
FROM torrents ORDER BY ingested_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.context("browse_torrents")
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT t.event_id, t.info_hash, t.title, COALESCE(t.category,'') AS category,
|
||||
t.size_bytes, t.pubkey, t.ingested_at
|
||||
FROM torrents t
|
||||
WHERE t.rowid IN (SELECT rowid FROM torrents_fts WHERE torrents_fts MATCH ?)
|
||||
ORDER BY t.ingested_at DESC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(q)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.context("browse_torrents fts")
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn count_browse(pool: &SqlitePool, q: &str) -> anyhow::Result<i64> {
|
||||
if q.is_empty() {
|
||||
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM torrents")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(n)
|
||||
} else {
|
||||
let (n,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM torrents_fts WHERE torrents_fts MATCH ?")
|
||||
.bind(q)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct QueueRow {
|
||||
pub id: i64,
|
||||
pub info_hash: String,
|
||||
pub title: String,
|
||||
pub category: String,
|
||||
pub queued_at: i64,
|
||||
pub scheduled_at: i64,
|
||||
pub published_at: Option<i64>,
|
||||
pub event_id: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for QueueRow {
|
||||
fn from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
|
||||
use sqlx::Row;
|
||||
Ok(QueueRow {
|
||||
id: row.try_get("id")?,
|
||||
info_hash: row.try_get("info_hash")?,
|
||||
title: row.try_get("title")?,
|
||||
category: row.try_get("category").unwrap_or_default(),
|
||||
queued_at: row.try_get("queued_at")?,
|
||||
scheduled_at: row.try_get("scheduled_at")?,
|
||||
published_at: row.try_get("published_at")?,
|
||||
event_id: row.try_get("event_id")?,
|
||||
error: row.try_get("error")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_queue_items(
|
||||
pool: &SqlitePool,
|
||||
done: Option<bool>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> anyhow::Result<Vec<QueueRow>> {
|
||||
let sql = match done {
|
||||
None => "SELECT id,info_hash,title,COALESCE(category,'') AS category,queued_at,scheduled_at,published_at,event_id,error FROM publish_queue ORDER BY queued_at DESC LIMIT ? OFFSET ?",
|
||||
Some(true) => "SELECT id,info_hash,title,COALESCE(category,'') AS category,queued_at,scheduled_at,published_at,event_id,error FROM publish_queue WHERE published_at IS NOT NULL ORDER BY published_at DESC LIMIT ? OFFSET ?",
|
||||
Some(false) => "SELECT id,info_hash,title,COALESCE(category,'') AS category,queued_at,scheduled_at,published_at,event_id,error FROM publish_queue WHERE published_at IS NULL ORDER BY scheduled_at ASC LIMIT ? OFFSET ?",
|
||||
};
|
||||
Ok(sqlx::query_as(sql)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn count_queue_items(pool: &SqlitePool, done: Option<bool>) -> anyhow::Result<i64> {
|
||||
let sql = match done {
|
||||
None => "SELECT COUNT(*) FROM publish_queue",
|
||||
Some(true) => "SELECT COUNT(*) FROM publish_queue WHERE published_at IS NOT NULL",
|
||||
Some(false) => "SELECT COUNT(*) FROM publish_queue WHERE published_at IS NULL",
|
||||
};
|
||||
let (n,): (i64,) = sqlx::query_as(sql).fetch_one(pool).await.unwrap_or((0,));
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn null_str(s: &str) -> Option<&str> {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod enrich;
|
||||
pub mod nostr;
|
||||
pub mod publisher;
|
||||
pub mod torznab;
|
||||
pub mod ui;
|
||||
pub mod wot;
|
||||
|
||||
use std::{
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
use anyhow::Context;
|
||||
use axum::{extract::State, routing::get, Json, Router};
|
||||
use clap::Parser;
|
||||
use kindexr::{config, db, enrich::tmdb::Enricher, nostr, publisher, torznab, wot, AppState};
|
||||
use kindexr::{config, db, enrich::tmdb::Enricher, nostr, publisher, torznab, ui, wot, AppState};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
sync::{atomic::AtomicI32, Arc},
|
||||
@@ -85,6 +85,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let app = Router::new()
|
||||
.route("/health", get(health_handler))
|
||||
.merge(torznab::server::router(state.clone()))
|
||||
.merge(ui::router())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&cfg.server.listen)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use axum::extract::State;
|
||||
use axum::response::Html;
|
||||
use crate::{db, AppState};
|
||||
use super::{page, err_page, fmt_ts_ago, short_pubkey, esc};
|
||||
|
||||
pub async fn handler(State(state): State<AppState>) -> Html<String> {
|
||||
match render(&state).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => err_page(&e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn render(state: &AppState) -> anyhow::Result<Html<String>> {
|
||||
let stats = db::ui_stats(&state.pool).await?;
|
||||
let relays = db::list_relays_ui(&state.pool).await.unwrap_or_default();
|
||||
let recent = db::recent_torrents(&state.pool, 12).await.unwrap_or_default();
|
||||
|
||||
// Stats row
|
||||
let last_seen = stats
|
||||
.last_event_at
|
||||
.map(|ts| fmt_ts_ago(ts))
|
||||
.unwrap_or_else(|| "never".into());
|
||||
|
||||
let stats_html = format!(
|
||||
r#"<div class="stats">
|
||||
<div class="stat"><div class="val">{}</div><div class="lbl">Indexed</div></div>
|
||||
<div class="stat"><div class="val">{}</div><div class="lbl">Publishers</div></div>
|
||||
<div class="stat"><div class="val">{}</div><div class="lbl">Queued</div></div>
|
||||
<div class="stat"><div class="val">{}</div><div class="lbl">Published</div></div>
|
||||
<div class="stat"><div class="val" style="font-size:1rem;padding-top:.2rem">{last_seen}</div><div class="lbl">Last ingest</div></div>
|
||||
</div>"#,
|
||||
stats.torrent_count,
|
||||
stats.publisher_count,
|
||||
stats.queued_count,
|
||||
stats.published_count,
|
||||
);
|
||||
|
||||
// Relay status
|
||||
let relay_rows: String = if relays.is_empty() {
|
||||
"<div class='empty'>No relays configured</div>".into()
|
||||
} else {
|
||||
relays
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let (dot, ts_str) = match r.last_event {
|
||||
Some(ts) => ("dot-ok", format!("last event {}", fmt_ts_ago(ts))),
|
||||
None => ("dot-dim", "no events yet".into()),
|
||||
};
|
||||
format!(
|
||||
r#"<div class="relay-row">
|
||||
<span><span class="dot {dot}"></span><span class="relay-url">{url}</span></span>
|
||||
<span class="relay-ts">{ts_str}</span>
|
||||
</div>"#,
|
||||
url = esc(&r.url),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let relays_html = format!(
|
||||
r#"<div class="section">
|
||||
<div class="section-title">Relays ({} configured)</div>
|
||||
<div class="relay-list">{relay_rows}</div>
|
||||
</div>"#,
|
||||
relays.len(),
|
||||
);
|
||||
|
||||
// Recent ingest table
|
||||
let recent_rows: String = if recent.is_empty() {
|
||||
"<tr><td colspan='4' class='empty'>Nothing indexed yet</td></tr>".into()
|
||||
} else {
|
||||
recent
|
||||
.iter()
|
||||
.map(|r| {
|
||||
format!(
|
||||
r#"<tr>
|
||||
<td>{title}</td>
|
||||
<td><span class="badge badge-dim">{cat}</span></td>
|
||||
<td class="mono">{pk}</td>
|
||||
<td class="mono">{ts}</td>
|
||||
</tr>"#,
|
||||
title = esc(&r.title),
|
||||
cat = esc(&r.category),
|
||||
pk = short_pubkey(&r.pubkey),
|
||||
ts = fmt_ts_ago(r.ingested_at),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let recent_html = format!(
|
||||
r#"<div class="section">
|
||||
<div class="section-title">Recent ingest</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Title</th><th>Category</th><th>Publisher</th><th>When</th></tr></thead>
|
||||
<tbody>{recent_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>"#
|
||||
);
|
||||
|
||||
let body = format!("<h1>Dashboard</h1>{stats_html}{relays_html}{recent_html}");
|
||||
Ok(page("dashboard", "Dashboard", &body))
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use crate::{db, AppState};
|
||||
use super::{page, err_page, fmt_ts, fmt_size, short_pubkey, esc, pagination};
|
||||
|
||||
const PAGE_SIZE: i64 = 50;
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct Params {
|
||||
pub q: Option<String>,
|
||||
pub page: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<Params>,
|
||||
) -> Html<String> {
|
||||
match render(&state, ¶ms).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => err_page(&e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn render(state: &AppState, params: &Params) -> anyhow::Result<Html<String>> {
|
||||
let q = params.q.as_deref().unwrap_or("").trim().to_owned();
|
||||
let page_num = params.page.unwrap_or(1).max(1);
|
||||
let offset = (page_num - 1) * PAGE_SIZE;
|
||||
|
||||
let total = db::count_browse(&state.pool, &q).await?;
|
||||
let rows = db::browse_torrents(&state.pool, &q, PAGE_SIZE, offset).await?;
|
||||
let total_pages = (total + PAGE_SIZE - 1) / PAGE_SIZE;
|
||||
|
||||
// Search bar
|
||||
let q_esc = esc(&q);
|
||||
let search = format!(
|
||||
r#"<form class="search-row" method="GET" action="/ui/indexed">
|
||||
<input type="text" name="q" value="{q_esc}" placeholder="Search title…">
|
||||
<button type="submit">Search</button>
|
||||
{clear}
|
||||
</form>"#,
|
||||
clear = if !q.is_empty() {
|
||||
r#"<a href="/ui/indexed" class="btn">Clear</a>"#
|
||||
} else {
|
||||
""
|
||||
},
|
||||
);
|
||||
|
||||
let table_rows: String = if rows.is_empty() {
|
||||
"<tr><td colspan='6' class='empty'>Nothing found</td></tr>".into()
|
||||
} else {
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
let size = r
|
||||
.size_bytes
|
||||
.map(fmt_size)
|
||||
.unwrap_or_else(|| "—".into());
|
||||
format!(
|
||||
r#"<tr>
|
||||
<td>{title}</td>
|
||||
<td><span class="badge badge-dim">{cat}</span></td>
|
||||
<td class="mono">{size}</td>
|
||||
<td class="mono" title="{pk_full}">{pk}</td>
|
||||
<td class="mono" title="{hash}">{hash_short}</td>
|
||||
<td class="mono">{ts}</td>
|
||||
</tr>"#,
|
||||
title = esc(&r.title),
|
||||
cat = esc(&r.category),
|
||||
pk_full = esc(&r.pubkey),
|
||||
pk = short_pubkey(&r.pubkey),
|
||||
hash = esc(&r.info_hash),
|
||||
hash_short = &r.info_hash[..12],
|
||||
ts = fmt_ts(r.ingested_at),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let base_url = format!("/ui/indexed?q={q_esc}");
|
||||
let pager = pagination(&base_url, page_num, total_pages);
|
||||
|
||||
let body = format!(
|
||||
r#"<h1>Indexed <span style="font-weight:400;color:var(--muted);font-size:.9rem">({total} total)</span></h1>
|
||||
{search}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Title</th><th>Category</th><th>Size</th>
|
||||
<th>Publisher</th><th>Info Hash</th><th>Indexed</th>
|
||||
</tr></thead>
|
||||
<tbody>{table_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pager}"#
|
||||
);
|
||||
|
||||
Ok(page("indexed", "Indexed", &body))
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use crate::AppState;
|
||||
|
||||
mod dashboard;
|
||||
mod indexed;
|
||||
mod publishers;
|
||||
mod published;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/ui", get(dashboard::handler))
|
||||
.route("/ui/indexed", get(indexed::handler))
|
||||
.route("/ui/publishers", get(publishers::list_handler))
|
||||
.route("/ui/publishers/vouch", post(publishers::vouch_handler))
|
||||
.route("/ui/publishers/:pubkey/block", post(publishers::block_handler))
|
||||
.route("/ui/publishers/:pubkey/unblock", post(publishers::unblock_handler))
|
||||
.route("/ui/publishers/:pubkey/mute", post(publishers::mute_handler))
|
||||
.route("/ui/published", get(published::handler))
|
||||
}
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
pub fn page(active: &str, title: &str, body: &str) -> axum::response::Html<String> {
|
||||
let nav = [
|
||||
("dashboard", "/ui", "Dashboard"),
|
||||
("indexed", "/ui/indexed", "Indexed"),
|
||||
("publishers", "/ui/publishers", "Publishers"),
|
||||
("published", "/ui/published", "Published"),
|
||||
]
|
||||
.iter()
|
||||
.map(|(id, href, label)| {
|
||||
if *id == active {
|
||||
format!(r#"<a href="{href}" class="active">{label}</a>"#)
|
||||
} else {
|
||||
format!(r#"<a href="{href}">{label}</a>"#)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ");
|
||||
|
||||
axum::response::Html(format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title} — kindexr</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<span class="brand">kindexr</span>
|
||||
{nav}
|
||||
</nav>
|
||||
<main>
|
||||
{body}
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
css = CSS,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn err_page(e: &anyhow::Error) -> axum::response::Html<String> {
|
||||
page("", "Error", &format!("<div class='err-box'><strong>Error:</strong> {e}</div>"))
|
||||
}
|
||||
|
||||
pub fn esc(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
pub fn fmt_ts(ts: i64) -> String {
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
let d = UNIX_EPOCH + Duration::from_secs(ts.max(0) as u64);
|
||||
// Simple formatting without chrono dependency in this module
|
||||
// Use chrono which is already in Cargo.toml
|
||||
let dt = chrono::DateTime::<chrono::Utc>::from(d);
|
||||
dt.format("%Y-%m-%d %H:%M").to_string()
|
||||
}
|
||||
|
||||
pub fn fmt_ts_ago(ts: i64) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let diff = (now - ts).max(0);
|
||||
if diff < 60 {
|
||||
format!("{diff}s ago")
|
||||
} else if diff < 3600 {
|
||||
format!("{}m ago", diff / 60)
|
||||
} else if diff < 86400 {
|
||||
format!("{}h ago", diff / 3600)
|
||||
} else {
|
||||
format!("{}d ago", diff / 86400)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fmt_size(bytes: i64) -> String {
|
||||
const GIB: i64 = 1 << 30;
|
||||
const MIB: i64 = 1 << 20;
|
||||
const KIB: i64 = 1 << 10;
|
||||
if bytes >= GIB {
|
||||
format!("{:.1} GiB", bytes as f64 / GIB as f64)
|
||||
} else if bytes >= MIB {
|
||||
format!("{:.0} MiB", bytes as f64 / MIB as f64)
|
||||
} else if bytes >= KIB {
|
||||
format!("{:.0} KiB", bytes as f64 / KIB as f64)
|
||||
} else {
|
||||
format!("{bytes} B")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn short_pubkey(pk: &str) -> String {
|
||||
if pk.len() >= 12 {
|
||||
format!("{}…{}", &pk[..6], &pk[pk.len() - 6..])
|
||||
} else {
|
||||
pk.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pagination(base: &str, page: i64, total_pages: i64) -> String {
|
||||
if total_pages <= 1 {
|
||||
return String::new();
|
||||
}
|
||||
let mut parts = vec![r#"<div class="pager">"#.to_string()];
|
||||
if page > 1 {
|
||||
parts.push(format!(r#"<a href="{base}&page={}">←</a>"#, page - 1));
|
||||
}
|
||||
let start = (page - 2).max(1);
|
||||
let end = (page + 2).min(total_pages);
|
||||
for p in start..=end {
|
||||
if p == page {
|
||||
parts.push(format!(r#"<a class="cur" href="{base}&page={p}">{p}</a>"#));
|
||||
} else {
|
||||
parts.push(format!(r#"<a href="{base}&page={p}">{p}</a>"#));
|
||||
}
|
||||
}
|
||||
if page < total_pages {
|
||||
parts.push(format!(r#"<a href="{base}&page={}">→</a>"#, page + 1));
|
||||
}
|
||||
parts.push(format!(
|
||||
r#"<span>{page} / {total_pages}</span>"#
|
||||
));
|
||||
parts.push("</div>".to_string());
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
/// Try to normalise a pubkey string (npub bech32 or 64-char hex) to lowercase hex.
|
||||
pub fn parse_pubkey(s: &str) -> Option<String> {
|
||||
let s = s.trim();
|
||||
if s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Some(s.to_lowercase());
|
||||
}
|
||||
// try as npub bech32
|
||||
use nostr::FromBech32;
|
||||
nostr::PublicKey::from_bech32(s)
|
||||
.ok()
|
||||
.map(|pk| pk.to_hex())
|
||||
}
|
||||
|
||||
const CSS: &str = r#"
|
||||
:root {
|
||||
--bg: #0f1117; --bg2: #161b27; --bg3: #1e2438;
|
||||
--border: #2a3050; --text: #dde1f0; --muted: #6b7599;
|
||||
--accent: #818cf8; --success: #34d399; --danger: #f87171; --warn: #fbbf24;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: var(--bg); color: var(--text); font: 14px/1.6 system-ui, sans-serif; min-height: 100vh; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
nav { display: flex; align-items: center; gap: 1.5rem; padding: .6rem 1.5rem;
|
||||
background: var(--bg2); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
||||
.brand { font-weight: 700; color: var(--accent); margin-right: auto; letter-spacing: -.02em; }
|
||||
nav a { color: var(--muted); font-size: 13px; }
|
||||
nav a:hover, nav a.active { color: var(--text); text-decoration: none; }
|
||||
nav a.active { font-weight: 600; }
|
||||
main { padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
|
||||
h1 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1.25rem; }
|
||||
.stats { display: flex; gap: .75rem; flex-wrap: wrap; margin-bottom: 1.75rem; }
|
||||
.stat { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: .75rem 1.25rem; min-width: 140px; }
|
||||
.stat .val { font-size: 1.75rem; font-weight: 700; color: var(--accent); line-height: 1; margin-bottom: .15rem; }
|
||||
.stat .lbl { font-size: .68rem; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); }
|
||||
.section { margin-bottom: 2rem; }
|
||||
.section-title { font-size: .7rem; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--muted); margin-bottom: .75rem; font-weight: 600; }
|
||||
.table-wrap { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: var(--bg2); padding: .45rem .75rem; text-align: left; font-size: .68rem;
|
||||
text-transform: uppercase; letter-spacing: .07em; color: var(--muted);
|
||||
border-bottom: 1px solid var(--border); font-weight: 600; }
|
||||
td { padding: .45rem .75rem; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--bg3); }
|
||||
.mono { font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace; font-size: 11px; color: var(--muted); }
|
||||
.badge { display: inline-flex; align-items: center; padding: .12rem .45rem; border-radius: 4px;
|
||||
font-size: 11px; font-weight: 600; }
|
||||
.badge-ok { background: #052e1c; color: var(--success); }
|
||||
.badge-err { background: #2d0c0c; color: var(--danger); }
|
||||
.badge-warn { background: #2a1a02; color: var(--warn); }
|
||||
.badge-dim { background: var(--bg3); color: var(--muted); }
|
||||
.badge-accent { background: #1e2060; color: var(--accent); }
|
||||
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; }
|
||||
.dot-ok { background: var(--success); }
|
||||
.dot-dim { background: var(--muted); }
|
||||
.dot-err { background: var(--danger); }
|
||||
button, .btn { padding: .28rem .6rem; border-radius: 5px; border: 1px solid var(--border);
|
||||
cursor: pointer; font-size: 12px; font-weight: 600; background: var(--bg3); color: var(--text); }
|
||||
.btn-danger { background: #2d0c0c; color: var(--danger); border-color: #5a1a1a; }
|
||||
.btn-ok { background: #052e1c; color: var(--success); border-color: #0a4a2b; }
|
||||
.btn-warn { background: #2a1a02; color: var(--warn); border-color: #5a3600; }
|
||||
.btn-accent { background: var(--accent); color: #0f1117; border-color: var(--accent); }
|
||||
.card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 1rem 1.25rem; margin-bottom: 1.5rem; }
|
||||
.card-title { font-size: .7rem; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--muted); margin-bottom: .75rem; font-weight: 600; }
|
||||
.form-row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
|
||||
input[type=text], input[type=number], select {
|
||||
padding: .38rem .7rem; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 5px; color: var(--text); font-size: 13px; outline: none; }
|
||||
input[type=text]:focus, select:focus { border-color: var(--accent); }
|
||||
input.mono-in { font-family: monospace; }
|
||||
input.wide { flex: 1; min-width: 260px; }
|
||||
.search-row { display: flex; gap: .6rem; margin-bottom: 1rem; }
|
||||
.search-row input { flex: 1; max-width: 360px; }
|
||||
.search-row button { padding: .38rem .9rem; background: var(--accent); color: #0f1117;
|
||||
border: none; border-radius: 5px; font-weight: 600; cursor: pointer; }
|
||||
.pager { display: flex; gap: .4rem; margin-top: 1rem; align-items: center; }
|
||||
.pager a { padding: .22rem .55rem; border-radius: 4px; background: var(--bg2);
|
||||
border: 1px solid var(--border); color: var(--muted); font-size: 12px; }
|
||||
.pager a:hover, .pager a.cur { background: var(--accent); color: #0f1117;
|
||||
border-color: var(--accent); text-decoration: none; }
|
||||
.pager span { color: var(--muted); font-size: 12px; }
|
||||
.empty { color: var(--muted); font-style: italic; padding: 1.5rem .75rem; text-align: center; }
|
||||
.err-box { background: #2d0c0c; border: 1px solid #5a1a1a; color: var(--danger);
|
||||
border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
|
||||
.msg-ok { background: #052e1c; border: 1px solid #0a4a2b; color: var(--success);
|
||||
border-radius: 8px; padding: .6rem 1rem; margin-bottom: 1rem; font-size: 13px; }
|
||||
.msg-err { background: #2d0c0c; border: 1px solid #5a1a1a; color: var(--danger);
|
||||
border-radius: 8px; padding: .6rem 1rem; margin-bottom: 1rem; font-size: 13px; }
|
||||
form.ifrm { display: inline; }
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 1rem; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; width: fit-content; }
|
||||
.tabs a { padding: .35rem .9rem; font-size: 12px; font-weight: 600; color: var(--muted); background: var(--bg2); }
|
||||
.tabs a:hover { color: var(--text); text-decoration: none; }
|
||||
.tabs a.cur { background: var(--accent); color: #0f1117; }
|
||||
.relay-list { display: flex; flex-direction: column; gap: .4rem; }
|
||||
.relay-row { display: flex; align-items: center; justify-content: space-between;
|
||||
background: var(--bg3); border-radius: 5px; padding: .4rem .75rem; font-size: 13px; }
|
||||
.relay-url { font-family: monospace; font-size: 12px; color: var(--text); }
|
||||
.relay-ts { font-size: 11px; color: var(--muted); }
|
||||
"#;
|
||||
@@ -0,0 +1,113 @@
|
||||
use axum::extract::{Query, State};
|
||||
use axum::response::Html;
|
||||
use serde::Deserialize;
|
||||
use crate::{db, AppState};
|
||||
use super::{page, err_page, fmt_ts, fmt_ts_ago, esc, pagination};
|
||||
|
||||
const PAGE_SIZE: i64 = 50;
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct Params {
|
||||
pub tab: Option<String>,
|
||||
pub page: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn handler(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<Params>,
|
||||
) -> Html<String> {
|
||||
match render(&state, ¶ms).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => err_page(&e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn render(state: &AppState, params: &Params) -> anyhow::Result<Html<String>> {
|
||||
let tab = params.tab.as_deref().unwrap_or("done");
|
||||
let page_num = params.page.unwrap_or(1).max(1);
|
||||
let offset = (page_num - 1) * PAGE_SIZE;
|
||||
|
||||
let done_filter = match tab {
|
||||
"pending" => Some(false),
|
||||
_ => Some(true),
|
||||
};
|
||||
|
||||
let rows = db::list_queue_items(&state.pool, done_filter, PAGE_SIZE, offset).await?;
|
||||
let total = db::count_queue_items(&state.pool, done_filter).await?;
|
||||
let total_pages = (total + PAGE_SIZE - 1) / PAGE_SIZE;
|
||||
|
||||
let tabs = format!(
|
||||
r#"<div class="tabs">
|
||||
<a href="/ui/published?tab=done" class="{c_done}">Published</a>
|
||||
<a href="/ui/published?tab=pending" class="{c_pend}">Pending queue</a>
|
||||
</div>"#,
|
||||
c_done = if tab == "done" { "cur" } else { "" },
|
||||
c_pend = if tab == "pending" { "cur" } else { "" },
|
||||
);
|
||||
|
||||
let table_rows: String = if rows.is_empty() {
|
||||
"<tr><td colspan='6' class='empty'>Nothing here yet</td></tr>".into()
|
||||
} else {
|
||||
rows.iter().map(|r| {
|
||||
let status: &str = if r.error.is_some() {
|
||||
r#"<span class="badge badge-err">error</span>"#
|
||||
} else if r.published_at.is_some() {
|
||||
r#"<span class="badge badge-ok">published</span>"#
|
||||
} else {
|
||||
r#"<span class="badge badge-warn">pending</span>"#
|
||||
};
|
||||
|
||||
let event_cell = match &r.event_id {
|
||||
Some(id) => format!(r#"<span class="mono" title="{full}">{short}…</span>"#,
|
||||
full = esc(id), short = &id[..12]),
|
||||
None => match &r.error {
|
||||
Some(e) => format!(r#"<span class="mono" style="color:var(--danger)" title="{}">[error]</span>"#, esc(e)),
|
||||
None => {
|
||||
let sched = fmt_ts_ago(r.scheduled_at);
|
||||
format!(r#"<span class="mono" style="color:var(--muted)">in {sched}</span>"#)
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let pub_ts = r.published_at
|
||||
.map(|ts| fmt_ts(ts))
|
||||
.unwrap_or_else(|| fmt_ts(r.scheduled_at));
|
||||
|
||||
format!(
|
||||
r#"<tr>
|
||||
<td>{title}</td>
|
||||
<td><span class="badge badge-dim">{cat}</span></td>
|
||||
<td class="mono" title="{hash}">{hash_short}</td>
|
||||
<td>{status}</td>
|
||||
<td>{event_cell}</td>
|
||||
<td class="mono">{ts}</td>
|
||||
</tr>"#,
|
||||
title = esc(&r.title),
|
||||
cat = esc(&r.category),
|
||||
hash = esc(&r.info_hash),
|
||||
hash_short = &r.info_hash[..12],
|
||||
ts = pub_ts,
|
||||
)
|
||||
}).collect()
|
||||
};
|
||||
|
||||
let base_url = format!("/ui/published?tab={tab}");
|
||||
let pager = pagination(&base_url, page_num, total_pages);
|
||||
|
||||
let body = format!(
|
||||
r#"<h1>Published <span style="font-weight:400;color:var(--muted);font-size:.9rem">({total} items)</span></h1>
|
||||
{tabs}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Title</th><th>Category</th><th>Info Hash</th>
|
||||
<th>Status</th><th>Event ID</th><th>Time</th>
|
||||
</tr></thead>
|
||||
<tbody>{table_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pager}"#
|
||||
);
|
||||
|
||||
Ok(page("published", "Published", &body))
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
response::{Html, Redirect},
|
||||
Form,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use crate::{db, AppState};
|
||||
use super::{page, err_page, esc, fmt_ts_ago, short_pubkey, parse_pubkey, pagination};
|
||||
|
||||
const PAGE_SIZE: i64 = 50;
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct ListParams {
|
||||
pub page: Option<i64>,
|
||||
pub msg: Option<String>,
|
||||
pub err: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_handler(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ListParams>,
|
||||
) -> Html<String> {
|
||||
match render(&state, ¶ms).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => err_page(&e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn render(state: &AppState, params: &ListParams) -> anyhow::Result<Html<String>> {
|
||||
let page_num = params.page.unwrap_or(1).max(1);
|
||||
let offset = (page_num - 1) * PAGE_SIZE;
|
||||
|
||||
let rows = db::list_publishers(&state.pool, PAGE_SIZE, offset).await?;
|
||||
|
||||
// Rough total — just count what we have for pagination hint
|
||||
let total = db::ui_stats(&state.pool).await?.publisher_count;
|
||||
let total_pages = (total + PAGE_SIZE - 1) / PAGE_SIZE;
|
||||
|
||||
// Flash message
|
||||
let flash = if let Some(msg) = ¶ms.msg {
|
||||
format!(r#"<div class="msg-ok">{}</div>"#, esc(msg))
|
||||
} else if let Some(err) = ¶ms.err {
|
||||
format!(r#"<div class="msg-err">{}</div>"#, esc(err))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Vouch form
|
||||
let vouch_form = r#"<div class="card">
|
||||
<div class="card-title">Vouch for a publisher</div>
|
||||
<form method="POST" action="/ui/publishers/vouch">
|
||||
<div class="form-row">
|
||||
<input class="wide mono-in" type="text" name="pubkey"
|
||||
placeholder="npub1… or 64-char hex pubkey" required>
|
||||
<select name="action">
|
||||
<option value="trusted">Trusted (score 1.0)</option>
|
||||
<option value="neutral">Neutral (score 0.5)</option>
|
||||
<option value="untrusted">Untrusted (score 0.0)</option>
|
||||
<option value="block">Block</option>
|
||||
<option value="mute">Mute</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-accent">Apply</button>
|
||||
</div>
|
||||
<div style="margin-top:.5rem;font-size:11px;color:var(--muted)">
|
||||
Vouching sets the trust score for this publisher independently of the WoT graph.
|
||||
Blocked publishers' events are silently dropped at ingest.
|
||||
</div>
|
||||
</form>
|
||||
</div>"#;
|
||||
|
||||
// Publishers table
|
||||
let table_rows: String = if rows.is_empty() {
|
||||
"<tr><td colspan='7' class='empty'>No publishers seen yet</td></tr>".into()
|
||||
} else {
|
||||
rows.iter().map(|r| {
|
||||
let trust_badge = if r.blocked {
|
||||
r#"<span class="badge badge-err">blocked</span>"#
|
||||
} else if r.muted {
|
||||
r#"<span class="badge badge-warn">muted</span>"#
|
||||
} else if r.trust >= 0.8 {
|
||||
r#"<span class="badge badge-ok">trusted</span>"#
|
||||
} else if r.trust <= 0.0 {
|
||||
r#"<span class="badge badge-dim">untrusted</span>"#
|
||||
} else {
|
||||
r#"<span class="badge badge-accent">neutral</span>"#
|
||||
};
|
||||
|
||||
let wot = r.wot_level
|
||||
.map(|l| format!("{l}"))
|
||||
.unwrap_or_else(|| "—".into());
|
||||
|
||||
let last = r.last_seen
|
||||
.map(fmt_ts_ago)
|
||||
.unwrap_or_else(|| "—".into());
|
||||
|
||||
let pk = &r.pubkey;
|
||||
|
||||
// Action buttons — wrapped in inline forms
|
||||
let block_btn = if r.blocked {
|
||||
format!(
|
||||
r#"<form class="ifrm" method="POST" action="/ui/publishers/{pk}/unblock">
|
||||
<button class="btn-ok" type="submit">Unblock</button></form>"#
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"<form class="ifrm" method="POST" action="/ui/publishers/{pk}/block">
|
||||
<button class="btn-danger" type="submit">Block</button></form>"#
|
||||
)
|
||||
};
|
||||
|
||||
let mute_btn = if !r.muted {
|
||||
format!(
|
||||
r#"<form class="ifrm" method="POST" action="/ui/publishers/{pk}/mute">
|
||||
<button class="btn-warn" type="submit">Mute</button></form>"#
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<tr>
|
||||
<td class="mono" title="{pk}">{short}</td>
|
||||
<td>{trust_badge} <span style="font-size:11px;color:var(--muted)">{score:.2}</span></td>
|
||||
<td class="mono">{wot}</td>
|
||||
<td class="mono">{events}</td>
|
||||
<td class="mono">{reports}</td>
|
||||
<td class="mono">{last}</td>
|
||||
<td style="white-space:nowrap">{block_btn} {mute_btn}</td>
|
||||
</tr>"#,
|
||||
pk = esc(pk),
|
||||
short = short_pubkey(pk),
|
||||
score = r.trust,
|
||||
events = r.torrents_n,
|
||||
reports = r.report_count,
|
||||
)
|
||||
}).collect()
|
||||
};
|
||||
|
||||
let pager = pagination("/ui/publishers?", page_num, total_pages);
|
||||
|
||||
let body = format!(
|
||||
r#"<h1>Publishers</h1>
|
||||
{flash}
|
||||
{vouch_form}
|
||||
<div class="section">
|
||||
<div class="section-title">{total} known publishers</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Pubkey</th><th>Trust</th><th>WoT level</th>
|
||||
<th>Events</th><th>Reports</th><th>Last seen</th><th>Actions</th>
|
||||
</tr></thead>
|
||||
<tbody>{table_rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pager}
|
||||
</div>"#
|
||||
);
|
||||
|
||||
Ok(page("publishers", "Publishers", &body))
|
||||
}
|
||||
|
||||
// ─── POST handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VouchForm {
|
||||
pub pubkey: String,
|
||||
pub action: String,
|
||||
}
|
||||
|
||||
pub async fn vouch_handler(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<VouchForm>,
|
||||
) -> Redirect {
|
||||
let Some(hex) = parse_pubkey(&form.pubkey) else {
|
||||
return Redirect::to("/ui/publishers?err=Invalid+pubkey+%28use+npub+or+64-char+hex%29");
|
||||
};
|
||||
|
||||
let result = match form.action.as_str() {
|
||||
"trusted" => db::set_publisher_trust(&state.pool, &hex, 1.0).await,
|
||||
"neutral" => db::set_publisher_trust(&state.pool, &hex, 0.5).await,
|
||||
"untrusted" => db::set_publisher_trust(&state.pool, &hex, 0.0).await,
|
||||
"block" => db::block_publisher(&state.pool, &hex).await,
|
||||
"mute" => db::mute_publisher(&state.pool, &hex).await,
|
||||
_ => return Redirect::to("/ui/publishers?err=Unknown+action"),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(_) => Redirect::to(&format!("/ui/publishers?msg=Applied+to+{}", &hex[..12])),
|
||||
Err(_) => Redirect::to("/ui/publishers?err=DB+error+applying+action"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn block_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> Redirect {
|
||||
let _ = db::block_publisher(&state.pool, &pubkey).await;
|
||||
Redirect::to("/ui/publishers?msg=Blocked")
|
||||
}
|
||||
|
||||
pub async fn unblock_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> Redirect {
|
||||
let _ = db::unblock_publisher(&state.pool, &pubkey).await;
|
||||
Redirect::to("/ui/publishers?msg=Unblocked")
|
||||
}
|
||||
|
||||
pub async fn mute_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(pubkey): Path<String>,
|
||||
) -> Redirect {
|
||||
let _ = db::mute_publisher(&state.pool, &pubkey).await;
|
||||
Redirect::to("/ui/publishers?msg=Muted")
|
||||
}
|
||||
Reference in New Issue
Block a user