mirror of
https://github.com/4cuck/teapawt.git
synced 2026-10-27 19:04:35 +00:00
Preview the linked photo for unfurl bots and keep slash URLs on the same page.
Reply chains also get the thread class so the connector between them draws.
This commit is contained in:
1 parent
3adc9754c6
commit
b80b1960cf
9 files changed
+258
-44
No files matched your search
@@ -62,6 +62,7 @@ tower-http = {
|
||||
"fs",
|
||||
"cors",
|
||||
"compression-gzip",
|
||||
"normalize-path",
|
||||
"set-header",
|
||||
"timeout",
|
||||
],
|
||||
|
||||
+11
-2
@@ -21,6 +21,8 @@ use std::{
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
ServiceExt,
|
||||
extract::Request,
|
||||
http::{
|
||||
StatusCode,
|
||||
header::HeaderValue,
|
||||
@@ -29,8 +31,12 @@ use axum::{
|
||||
};
|
||||
use axum::http::header;
|
||||
use tokio::net::TcpListener;
|
||||
use tower::limit::ConcurrencyLimitLayer;
|
||||
use tower::{
|
||||
Layer as _,
|
||||
limit::ConcurrencyLimitLayer,
|
||||
};
|
||||
use tower_http::{
|
||||
normalize_path::NormalizePathLayer,
|
||||
services::{
|
||||
ServeDir,
|
||||
ServeFile,
|
||||
@@ -240,9 +246,12 @@ async fn main() -> eyre::Result<()> {
|
||||
let listener = TcpListener::bind(addr).await?;
|
||||
|
||||
tracing::info!("Listening on {addr}");
|
||||
// `/user/` and `/user/status/id/` are the same pages as the slash-free paths.
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
ServiceExt::<Request>::into_make_service_with_connect_info::<SocketAddr>(
|
||||
NormalizePathLayer::trim_trailing_slash().layer(app),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
+19
-11
@@ -1,3 +1,5 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{
|
||||
@@ -36,6 +38,7 @@ use crate::{
|
||||
utils::formatters::format_relative_time,
|
||||
views::{
|
||||
embed as embed_view,
|
||||
embed::ActivityId,
|
||||
layout::strip_html,
|
||||
},
|
||||
};
|
||||
@@ -67,10 +70,10 @@ pub fn router() -> Router<AppState> {
|
||||
// Legacy embed URL support (Twitter's old embed format)
|
||||
.route("/embed/Tweet.html", get(legacy_embed_redirect))
|
||||
// ActivityPub endpoint for Discord multi-image support
|
||||
.route("/users/{username}/statuses/{id}", get(activity_pub_status))
|
||||
.route("/users/{username}/statuses/{status}", get(activity_pub_status))
|
||||
// Discord turns the Mastodon-looking discovery URL above into this
|
||||
// API request. The discovery URL itself is not the JSON endpoint.
|
||||
.route("/api/v1/statuses/{id}", get(mastodon_status))
|
||||
.route("/api/v1/statuses/{status}", get(mastodon_status))
|
||||
// oEmbed endpoints
|
||||
.route("/owoembed", get(oembed))
|
||||
.route("/oembed", get(oembed_standard))
|
||||
@@ -117,9 +120,10 @@ async fn video_embed(State(state): State<AppState>, Path(id): Path<String>) -> R
|
||||
/// Discord fetches this to get all media attachments for carousel display.
|
||||
async fn activity_pub_status(
|
||||
State(state): State<AppState>,
|
||||
Path((username, id)): Path<(String, String)>,
|
||||
Path((username, status)): Path<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
let activity = ActivityId::from_str(&status)?;
|
||||
let accept = headers
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|hv| hv.to_str().ok())
|
||||
@@ -138,23 +142,23 @@ async fn activity_pub_status(
|
||||
|| accept.contains("application/activity+json")
|
||||
|| accept.contains("application/ld+json")
|
||||
{
|
||||
return activity_response(&state, &id).await;
|
||||
return activity_response(&state, activity).await;
|
||||
}
|
||||
|
||||
// Otherwise redirect to normal status page
|
||||
Ok(Redirect::to(&format!("/{username}/status/{id}")).into_response())
|
||||
Ok(Redirect::to(&format!("/{username}/status/{}", activity.tweet())).into_response())
|
||||
}
|
||||
|
||||
/// Mastodon API v1 status endpoint used by Discord after discovery.
|
||||
async fn mastodon_status(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Path(status): Path<String>,
|
||||
) -> Result<Response> {
|
||||
activity_response(&state, &id).await
|
||||
activity_response(&state, status.parse()?).await
|
||||
}
|
||||
|
||||
async fn activity_response(state: &AppState, id: &str) -> Result<Response> {
|
||||
let conversation = cached_conversation(state, id).await?;
|
||||
async fn activity_response(state: &AppState, activity: ActivityId) -> Result<Response> {
|
||||
let conversation = cached_conversation(state, &activity.tweet().to_string()).await?;
|
||||
let reply_id = conversation.tweet.reply_id;
|
||||
let mut replied_to = if reply_id > 0 {
|
||||
conversation
|
||||
@@ -167,6 +171,9 @@ async fn activity_response(state: &AppState, id: &str) -> Result<Response> {
|
||||
None
|
||||
};
|
||||
let mut tweet = conversation.tweet;
|
||||
if let Some(idx) = activity.photo_idx() {
|
||||
tweet.keep_only_photo(idx);
|
||||
}
|
||||
|
||||
if replied_to.is_none() && reply_id > 0 && reply_id != tweet.id {
|
||||
replied_to = cached_conversation(state, &reply_id.to_string())
|
||||
@@ -185,13 +192,14 @@ async fn activity_response(state: &AppState, id: &str) -> Result<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
let activity = replied_to.as_ref().map_or_else(
|
||||
let mut note = replied_to.as_ref().map_or_else(
|
||||
|| embed_view::build_activity_pub(&tweet, &state.config),
|
||||
|original| embed_view::build_activity_pub_with_reply(&tweet, Some(original), &state.config),
|
||||
);
|
||||
note.id = activity.to_string();
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
|
||||
Json(activity),
|
||||
Json(note),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
+99
-18
@@ -61,6 +61,28 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
const PREVIEW_AGENTS: [&str; 6] = [
|
||||
"Discordbot/",
|
||||
"Slackbot",
|
||||
"TelegramBot",
|
||||
"WhatsApp",
|
||||
"facebookexternalhit/",
|
||||
"Twitterbot/",
|
||||
];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StatusParams {
|
||||
username: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PhotoParams {
|
||||
username: String,
|
||||
id: String,
|
||||
idx: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StatusQuery {
|
||||
pub cursor: Option<String>,
|
||||
@@ -89,7 +111,7 @@ pub fn router() -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
&format!("/{{username}}/{prefix}/{{id}}/photo/{{idx}}"),
|
||||
get(status_media_redirect),
|
||||
get(status_photo),
|
||||
)
|
||||
.route(
|
||||
&format!("/{{username}}/{prefix}/{{id}}/video"),
|
||||
@@ -125,16 +147,47 @@ async fn thread_redirect(Path((username, id)): Path<(String, String)>) -> Respon
|
||||
Redirect::to(&format!("/{username}/status/{id}")).into_response()
|
||||
}
|
||||
|
||||
/// Redirect legacy media URLs (photo/video/history) to the main status page.
|
||||
async fn status_media_redirect(Path((username, id)): Path<(String, String)>) -> Response {
|
||||
Redirect::to(&format!("/{username}/status/{id}")).into_response()
|
||||
/// Redirect legacy media URLs (photo/video) to the main status page.
|
||||
/// Named fields so `/video/1` still redirects; the extra index is ignored.
|
||||
async fn status_media_redirect(Path(params): Path<StatusParams>) -> Response {
|
||||
Redirect::to(&format!("/{}/status/{}", params.username, params.id)).into_response()
|
||||
}
|
||||
|
||||
/// Link unfurlers get the status page narrowed to the one photo, so the
|
||||
/// preview shows that image instead of the whole post's media.
|
||||
async fn status_photo(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
Path(params): Path<PhotoParams>,
|
||||
Query(query): Query<StatusQuery>,
|
||||
) -> Result<Response> {
|
||||
let user_agent = headers
|
||||
.get(header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !PREVIEW_AGENTS
|
||||
.iter()
|
||||
.any(|agent| user_agent.contains(agent))
|
||||
{
|
||||
return Ok(
|
||||
Redirect::to(&format!("/{}/status/{}", params.username, params.id)).into_response(),
|
||||
);
|
||||
}
|
||||
|
||||
render_status(
|
||||
state,
|
||||
&jar,
|
||||
&headers,
|
||||
params.username,
|
||||
params.id,
|
||||
query,
|
||||
params.idx.checked_sub(1),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::cognitive_complexity,
|
||||
reason = "status handler has many conditional branches for caching, scrolling, pagination, and \
|
||||
translation"
|
||||
)]
|
||||
async fn status(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
@@ -142,9 +195,25 @@ async fn status(
|
||||
Path((username, id)): Path<(String, String)>,
|
||||
Query(query): Query<StatusQuery>,
|
||||
) -> Result<Response> {
|
||||
// Extract prefs from cookies
|
||||
let prefs = Prefs::from_cookies(&jar, &state.config);
|
||||
let discord_activity = headers
|
||||
render_status(state, &jar, &headers, username, id, query, None).await
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::cognitive_complexity,
|
||||
reason = "status handler has many conditional branches for caching, scrolling, pagination, and \
|
||||
translation"
|
||||
)]
|
||||
async fn render_status(
|
||||
state: AppState,
|
||||
jar: &CookieJar,
|
||||
headers: &HeaderMap,
|
||||
username: String,
|
||||
id: String,
|
||||
query: StatusQuery,
|
||||
photo: Option<usize>,
|
||||
) -> Result<Response> {
|
||||
let prefs = Prefs::from_cookies(jar, &state.config);
|
||||
let is_discord = headers
|
||||
.get(header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|user_agent| user_agent.contains("Discordbot"));
|
||||
@@ -188,8 +257,12 @@ async fn status(
|
||||
|
||||
helpers::enrich_conversation(&state, &mut conversation).await;
|
||||
|
||||
let focused_photo = photo.filter(|&idx| conversation.tweet.keep_only_photo(idx));
|
||||
let activity =
|
||||
is_discord.then(|| embed::ActivityId::new(conversation.tweet.id, focused_photo));
|
||||
|
||||
// Auto-translate for embeds (OG tags / ActivityPub show translated text)
|
||||
if !discord_activity && !is_scroll && !has_cursor && conversation.tweet.is_translatable {
|
||||
if !is_discord && !is_scroll && !has_cursor && conversation.tweet.is_translatable {
|
||||
let kagi = &state.config.config.kagi_token;
|
||||
let token = (!kagi.is_empty()).then_some(kagi.as_str());
|
||||
if let Ok(tl) = get_cached_translation(&state, &conversation.tweet, token).await
|
||||
@@ -240,6 +313,9 @@ async fn status(
|
||||
}
|
||||
};
|
||||
first_page.replies.bottom = None;
|
||||
if let Some(idx) = focused_photo {
|
||||
first_page.tweet.keep_only_photo(idx);
|
||||
}
|
||||
return Ok(render_conversation(
|
||||
&first_page,
|
||||
false,
|
||||
@@ -248,7 +324,7 @@ async fn status(
|
||||
&prefs,
|
||||
&state.config,
|
||||
query.sort.as_deref(),
|
||||
discord_activity,
|
||||
activity,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -270,6 +346,9 @@ async fn status(
|
||||
conversation.tweet = fresh.tweet;
|
||||
}
|
||||
}
|
||||
if let Some(idx) = focused_photo {
|
||||
conversation.tweet.keep_only_photo(idx);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(render_conversation(
|
||||
@@ -280,7 +359,7 @@ async fn status(
|
||||
&prefs,
|
||||
&state.config,
|
||||
query.sort.as_deref(),
|
||||
discord_activity,
|
||||
activity,
|
||||
))
|
||||
},
|
||||
Err(Error::TweetNotFound(msg)) => {
|
||||
@@ -304,7 +383,7 @@ fn render_conversation(
|
||||
prefs: &Prefs,
|
||||
config: &Config,
|
||||
sort: Option<&str>,
|
||||
discord_activity: bool,
|
||||
activity: Option<embed::ActivityId>,
|
||||
) -> Response {
|
||||
let tweet = &conversation.tweet;
|
||||
|
||||
@@ -461,7 +540,7 @@ fn render_conversation(
|
||||
config,
|
||||
username,
|
||||
id,
|
||||
discord_activity,
|
||||
activity,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -532,6 +611,8 @@ async fn status_by_id(
|
||||
}
|
||||
|
||||
let username = conversation.tweet.user.username.clone();
|
||||
let activity =
|
||||
discord_activity.then(|| embed::ActivityId::new(conversation.tweet.id, None));
|
||||
Ok(render_conversation(
|
||||
&conversation,
|
||||
query.cursor.is_some(),
|
||||
@@ -540,7 +621,7 @@ async fn status_by_id(
|
||||
&prefs,
|
||||
&state.config,
|
||||
query.sort.as_deref(),
|
||||
discord_activity,
|
||||
activity,
|
||||
))
|
||||
},
|
||||
Err(Error::TweetNotFound(msg)) => {
|
||||
|
||||
@@ -378,6 +378,16 @@ pub struct Translation {
|
||||
}
|
||||
|
||||
impl Tweet {
|
||||
/// Drop every photo but the one at `idx`, returning whether it existed.
|
||||
pub fn keep_only_photo(&mut self, idx: usize) -> bool {
|
||||
if idx >= self.photos.len() {
|
||||
return false;
|
||||
}
|
||||
self.photos.swap(0, idx);
|
||||
self.photos.truncate(1);
|
||||
true
|
||||
}
|
||||
|
||||
/// Check if this tweet has any media.
|
||||
pub const fn has_media(&self) -> bool {
|
||||
!self.photos.is_empty()
|
||||
@@ -412,6 +422,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_only_photo_moves_the_chosen_one_first() {
|
||||
let mut tweet = Tweet {
|
||||
photos: vec![
|
||||
Photo {
|
||||
url: "a".to_owned(),
|
||||
..Photo::default()
|
||||
},
|
||||
Photo {
|
||||
url: "b".to_owned(),
|
||||
..Photo::default()
|
||||
},
|
||||
],
|
||||
..Tweet::default()
|
||||
};
|
||||
assert!(tweet.keep_only_photo(1));
|
||||
assert_eq!(tweet.photos.len(), 1);
|
||||
assert_eq!(tweet.photos[0].url, "b");
|
||||
assert!(!tweet.keep_only_photo(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_limit_uses_bitrate_and_duration() {
|
||||
assert!(!video(2_000_000, 10_000).exceeds_cloudflare_cache());
|
||||
|
||||
+6
-4
@@ -573,9 +573,10 @@ pub fn render_status_page(
|
||||
config: &Config,
|
||||
username: &str,
|
||||
id: &str,
|
||||
discord_activity: bool,
|
||||
activity: Option<ActivityId>,
|
||||
) -> Markup {
|
||||
let url_prefix = config.url_prefix();
|
||||
let discord_activity = activity.is_some();
|
||||
let oembed_url = build_oembed_url(tweet, config);
|
||||
|
||||
let title = format!(
|
||||
@@ -616,11 +617,12 @@ pub fn render_status_page(
|
||||
meta property="og:title" content=(og_title);
|
||||
meta property="og:description" content=(description);
|
||||
meta property="og:site_name" content=(embed_provider_name(tweet, config));
|
||||
@if discord_activity {
|
||||
@if let Some(activity) = activity {
|
||||
// Discord recognizes this as a Mastodon status, then requests
|
||||
// `/api/v1/statuses/{id}` from the same host.
|
||||
// `/api/v1/statuses/{id}` from the same host. A photo unfurl
|
||||
// encodes the photo number into that id.
|
||||
link rel="alternate"
|
||||
href=(format!("{url_prefix}/users/{username}/statuses/{id}"))
|
||||
href=(format!("{url_prefix}/users/{username}/statuses/{activity}"))
|
||||
type="application/activity+json";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,70 @@
|
||||
use std::{
|
||||
fmt::{
|
||||
self,
|
||||
Display,
|
||||
Formatter,
|
||||
},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// The status id Discord echoes back from the activity link. A `/photo/N`
|
||||
/// link prefixes N to the tweet id zero-padded to 19 digits, a length no real
|
||||
/// snowflake reaches, so Discord's follow-up request still names the photo.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ActivityId {
|
||||
tweet: i64,
|
||||
photo: Option<u8>,
|
||||
}
|
||||
|
||||
impl ActivityId {
|
||||
pub fn new(tweet: i64, photo_idx: Option<usize>) -> Self {
|
||||
let photo = photo_idx
|
||||
.and_then(|idx| u8::try_from(idx + 1).ok())
|
||||
.filter(|number| *number <= 9);
|
||||
Self { tweet, photo }
|
||||
}
|
||||
|
||||
pub const fn tweet(self) -> i64 {
|
||||
self.tweet
|
||||
}
|
||||
|
||||
pub fn photo_idx(self) -> Option<usize> {
|
||||
self.photo.map(|number| usize::from(number) - 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ActivityId {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
match self.photo {
|
||||
Some(number) => write!(f, "{number}{:019}", self.tweet),
|
||||
None => write!(f, "{}", self.tweet),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ActivityId {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(raw: &str) -> Result<Self, Self::Err> {
|
||||
let invalid = || Error::InvalidUrl("Invalid id".into());
|
||||
if !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
|
||||
let (photo, digits) = match (raw.len(), raw.split_at_checked(1)) {
|
||||
(1..=19, _) => (None, raw),
|
||||
(20, Some((number, rest))) if number != "0" => (number.parse::<u8>().ok(), rest),
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
let tweet = digits.parse::<i64>().map_err(|_| invalid())?;
|
||||
Ok(Self { tweet, photo })
|
||||
}
|
||||
}
|
||||
|
||||
/// Mastodon API v1-compatible status for Discord embed support.
|
||||
/// Discord uses `created_at` for the footer timestamp and `content`
|
||||
/// for rich text rendering (blockquotes for quoted tweets).
|
||||
@@ -868,7 +933,24 @@ pub fn build_activity_pub_with_reply(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::application_name;
|
||||
use super::{
|
||||
ActivityId,
|
||||
application_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn activity_id_keeps_the_photo_across_discord_followup() {
|
||||
let id = ActivityId::new(2_101_716_895_007_203_333, Some(1));
|
||||
let encoded = id.to_string();
|
||||
assert_eq!(encoded, "22101716895007203333");
|
||||
let parsed: ActivityId = encoded.parse().unwrap();
|
||||
assert_eq!(parsed.tweet(), 2_101_716_895_007_203_333);
|
||||
assert_eq!(parsed.photo_idx(), Some(1));
|
||||
|
||||
let plain = ActivityId::new(401, None);
|
||||
assert_eq!(plain.to_string(), "401");
|
||||
assert_eq!("401".parse::<ActivityId>().unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_uses_source_label() {
|
||||
|
||||
@@ -290,7 +290,7 @@ fn activity_payload_renders_live_broadcast_preview() {
|
||||
&test_config(),
|
||||
"tbpn",
|
||||
"406",
|
||||
true,
|
||||
Some(ActivityId::new(406, None)),
|
||||
)
|
||||
.into_string();
|
||||
let image_url = format!(
|
||||
@@ -660,7 +660,7 @@ fn status_page_uses_author_first_embed_metadata() {
|
||||
&test_config(),
|
||||
"G2CSGO",
|
||||
"400",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.into_string();
|
||||
|
||||
@@ -700,7 +700,7 @@ fn discord_activity_page_defers_photo_media_to_mastodon_api() {
|
||||
&test_config(),
|
||||
"photos",
|
||||
"401",
|
||||
true,
|
||||
Some(ActivityId::new(401, None)),
|
||||
)
|
||||
.into_string();
|
||||
|
||||
@@ -724,7 +724,7 @@ fn discord_activity_page_uses_avatar_for_text_only_status() {
|
||||
&test_config(),
|
||||
"plain",
|
||||
"402",
|
||||
true,
|
||||
Some(ActivityId::new(402, None)),
|
||||
)
|
||||
.into_string();
|
||||
|
||||
@@ -750,7 +750,7 @@ fn generic_photo_metadata_includes_real_dimensions() {
|
||||
&test_config(),
|
||||
"photos",
|
||||
"403",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.into_string();
|
||||
|
||||
|
||||
+3
-3
@@ -202,9 +202,9 @@ impl<'a> TweetRenderer<'a> {
|
||||
classes.push(extra_class);
|
||||
}
|
||||
match thread_ctx {
|
||||
ThreadContext::Start => classes.push("thread-first"),
|
||||
ThreadContext::Middle => classes.push("thread-middle"),
|
||||
ThreadContext::End => classes.push("thread-last"),
|
||||
ThreadContext::Start => classes.extend(["thread", "thread-first"]),
|
||||
ThreadContext::Middle => classes.extend(["thread", "thread-middle"]),
|
||||
ThreadContext::End => classes.extend(["thread", "thread-last"]),
|
||||
ThreadContext::None => {},
|
||||
}
|
||||
let class = classes.join(" ");
|
||||
|
||||
Reference in new issue
Block a user