Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions data/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4556,6 +4556,16 @@ impl Map {
)
}

pub fn get_icon_url<'a>(&'a self, server: &Server) -> Option<&'a str> {
let client = self.client(server)?;

if server.is_bouncer_network() {
server.parent().as_ref().and_then(|p| self.get_icon_url(p))
} else {
isupport::get_icon_url(&client.isupport)
}
}

pub fn get_filehost_auth(
&self,
server: &Server,
Expand Down
15 changes: 15 additions & 0 deletions data/src/isupport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub enum Kind {
ELIST,
FILEHOST,
HOSTLEN,
ICON,
KEYLEN,
KICKLEN,
KNOCK,
Expand Down Expand Up @@ -323,6 +324,9 @@ impl FromStr for Operation {
"HOSTLEN" => Ok(Operation::Add(Parameter::HOSTLEN(
parse_required_positive_integer(value)?,
))),
"draft/ICON" => Ok(Operation::Add(Parameter::ICON(
value.to_owned(),
))),
"INVEX" => Ok(Operation::Add(Parameter::INVEX(
parse_required_letter(
value,
Expand Down Expand Up @@ -640,6 +644,7 @@ impl Operation {
"ELIST" => Some(Kind::ELIST),
"draft/FILEHOST" | "soju.im/FILEHOST" => Some(Kind::FILEHOST),
"HOSTLEN" => Some(Kind::HOSTLEN),
"draft/ICON" => Some(Kind::ICON),
"KEYLEN" => Some(Kind::KEYLEN),
"KICKLEN" => Some(Kind::KICKLEN),
"KNOCK" => Some(Kind::KNOCK),
Expand Down Expand Up @@ -701,6 +706,7 @@ pub enum Parameter {
FNC,
FILEHOST(String),
HOSTLEN(u16),
ICON(String),
INVEX(char),
KEYLEN(u16),
KICKLEN(u16),
Expand Down Expand Up @@ -754,6 +760,7 @@ impl Parameter {
Parameter::ELIST(_) => Some(Kind::ELIST),
Parameter::FILEHOST(_) => Some(Kind::FILEHOST),
Parameter::HOSTLEN(_) => Some(Kind::HOSTLEN),
Parameter::ICON(_) => Some(Kind::ICON),
Parameter::KEYLEN(_) => Some(Kind::KEYLEN),
Parameter::KICKLEN(_) => Some(Kind::KICKLEN),
Parameter::KNOCK => Some(Kind::KNOCK),
Expand Down Expand Up @@ -1340,6 +1347,14 @@ pub fn get_filehost(isupport: &HashMap<Kind, Parameter>) -> Option<&str> {
}
}

pub fn get_icon_url(isupport: &HashMap<Kind, Parameter>) -> Option<&str> {
if let Some(Parameter::ICON(url)) = isupport.get(&Kind::ICON) {
Some(url.as_str())
} else {
None
}
}

pub fn is_client_only_tag_denied(
isupport: &HashMap<Kind, Parameter>,
tag: &str,
Expand Down
1 change: 1 addition & 0 deletions data/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ pub mod rate_limit;
pub mod reaction;
pub mod serde;
pub mod server;
pub mod server_icon;
pub mod shortcut;
pub mod stream;
pub mod target;
Expand Down
194 changes: 194 additions & 0 deletions data/src/server_icon.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
use std::collections::HashMap;
use std::io;
use std::sync::Arc;

use iced::Task;
use sha2::{Digest, Sha256};
use tokio::fs;
use url::Url;

use self::icon::Icon;
use crate::Server;

mod cache;
mod icon;

#[derive(Debug)]
pub enum Message {
Loaded(Server, Url, Result<Icon, LoadError>),
}

pub struct Manager {
icons: HashMap<Server, Icon>,
pending: HashMap<Server, Url>,
}

impl Manager {
pub fn new() -> Self {
Self {
icons: HashMap::new(),
pending: HashMap::new(),
}
}

pub fn request(
&mut self,
server: Server,
icon_url: Option<String>,
http_client: Option<Arc<reqwest::Client>>,
) -> Task<Message> {
let Some(icon_url) = icon_url else {
self.drop_request(&server);
return Task::none();
};

let Ok(icon_url) = Url::parse(&icon_url) else {
log::debug!("invalid server icon URL for {server}: {icon_url}");
self.drop_request(&server);
return Task::none();
};

let Some(http_client) = http_client else {
log::warn!(
"[{}] File upload disabled: Unable to build HTTP client",
server
);
self.drop_request(&server);
return Task::none();
};

if self
.icons
.get(&server)
.is_some_and(|icon| icon.url == icon_url)
|| self.pending.get(&server) == Some(&icon_url)
{
return Task::none();
}

self.icons.remove(&server);
self.pending.insert(server.clone(), icon_url.clone());

Task::perform(load(icon_url.clone(), http_client), move |result| {
Message::Loaded(server.clone(), icon_url.clone(), result)
})
}

pub fn update(&mut self, message: Message) {
let Message::Loaded(server, icon_url, result) = message;

if self.pending.get(&server) != Some(&icon_url) {
log::trace!(
"ignoring stale server icon result for {server}: {icon_url}"
);
return;
}

self.pending.remove(&server);

match result {
Ok(icon) => {
self.icons.insert(server, icon);
}
Err(error) => {
log::debug!("failed to load server icon for {server}: {error}");
self.icons.remove(&server);
}
}
}

pub fn get(&self, server: &Server) -> Option<&Icon> {
self.icons.get(server)
}

fn drop_request(&mut self, server: &Server) {
self.pending.remove(server);
self.icons.remove(server);
}
}

fn canonical_icon_url(url: &Url) -> Url {
let mut canonical = url.clone();
canonical.set_fragment(None);
canonical
}

async fn load(
url: Url,
http_client: Arc<reqwest::Client>,
) -> Result<Icon, LoadError> {
let cache_key_url = canonical_icon_url(&url);

if let Some(state) = cache::load(&cache_key_url, http_client.clone()).await
{
match state {
cache::State::Ok(icon) => Ok(icon),
cache::State::Error => Err(LoadError::CachedFailed),
}
} else {
match fetch(url.clone(), http_client).await {
Ok(icon) => {
cache::save(&cache_key_url, cache::State::Ok(icon.clone()))
.await;

Ok(icon)
}
Err(error) => {
cache::save(&cache_key_url, cache::State::Error).await;

Err(error)
}
}
}
}

async fn fetch(
url: Url,
http_client: Arc<reqwest::Client>,
) -> Result<Icon, LoadError> {
let response = http_client
.get(url.clone())
.send()
.await?
.error_for_status()?;

let bytes = response.bytes().await?;

if bytes.is_empty() {
return Err(LoadError::EmptyBody);
}

let format = image::guess_format(&bytes).map_err(LoadError::ParseImage)?;

let mut hasher = Sha256::default();
hasher.update(bytes.as_ref());

let digest = icon::Digest::new(hasher.finalize().as_ref());
let image_path = cache::image_path(&format, &digest);

if !image_path.exists() {
if let Some(parent) = image_path.parent().filter(|p| !p.exists()) {
fs::create_dir_all(parent).await?;
}

fs::write(&image_path, bytes.as_ref()).await?;

cache::maybe_trim_icon_cache(bytes.len() as u64, image_path.clone());
}

Ok(Icon::new(format, url, digest))
}

#[derive(Debug, thiserror::Error)]
pub enum LoadError {
#[error("cached failed attempt")]
CachedFailed,
#[error("empty body")]
EmptyBody,
#[error("failed to parse image: {0}")]
ParseImage(#[from] icon::Error),
#[error("request failed: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("io error: {0}")]
Io(#[from] io::Error),
}
Loading