xenua
4 weeks ago
commit
089c4937c1
15 changed files with 3334 additions and 0 deletions
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
[package] |
||||
name = "fedi_emoji_downloader" |
||||
version = "0.1.0" |
||||
edition = "2021" |
||||
|
||||
[dependencies] |
||||
bytes = "1.8.0" |
||||
chrono = { version = "0.4.38", features = ["serde"] } |
||||
clap = { version = "4.5.21", features = ["derive"] } |
||||
color-eyre = "0.6.3" |
||||
gay_tracing_formatter = { git = "https://git.xenua.me/xenua/tracing_formatter.git", tag = "v0.1.2" } |
||||
inquire = "0.7.5" |
||||
reqwest = { version = "0.12.9", features = ["rustls-tls"], default-features = false } |
||||
serde = { version = "1.0.215", features = ["derive"] } |
||||
serde_json = "1.0.133" |
||||
sysinfo = { version = "0.32.0", features = ["system", "windows"], default-features = false } |
||||
term-detect = "0.1.7" |
||||
tokio = { version = "1.41.1", features = ["full"] } |
||||
tracing = "0.1.40" |
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } |
||||
|
||||
[build-dependencies] |
||||
rustc_version = "0.4.1" |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> { |
||||
let rcv = rustc_version::version_meta()?; |
||||
|
||||
println!("cargo::rustc-env=FED_RUSTC_VER={}", rcv.semver); |
||||
|
||||
Ok(()) |
||||
} |
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
use std::{ |
||||
path::PathBuf, |
||||
sync::{Arc, OnceLock}, |
||||
}; |
||||
|
||||
use clap::Parser; |
||||
use color_eyre::eyre::{eyre, Result, WrapErr}; |
||||
|
||||
use crate::misc::FediSoftware; |
||||
|
||||
#[derive(clap::Parser, Debug)] |
||||
#[command(version, about, long_about = None)] |
||||
pub struct Args { |
||||
#[arg(short, long)] |
||||
pub no_interactive: bool, |
||||
|
||||
#[arg(short, long)] |
||||
pub instance: Option<String>, |
||||
|
||||
#[arg(short, long)] |
||||
pub output_dir: Option<PathBuf>, |
||||
|
||||
#[arg(short, long)] |
||||
pub software: Option<FediSoftware>, |
||||
} |
||||
|
||||
pub fn get() -> Arc<Args> { |
||||
static ARGS: OnceLock<Arc<Args>> = OnceLock::new(); |
||||
ARGS.get_or_init(|| Arc::new(Args::parse())).clone() |
||||
} |
||||
|
||||
impl Args { |
||||
pub fn try_get_instance(&self) -> Result<String> { |
||||
static INSTANCE_CACHE: OnceLock<String> = OnceLock::new(); |
||||
|
||||
if let Some(inst) = INSTANCE_CACHE.get() { |
||||
return Ok(inst.clone()); |
||||
} |
||||
if let Some(inst) = self.instance.as_ref() { |
||||
return Ok(sanitize_instance(inst.clone())); |
||||
} |
||||
if self.no_interactive { |
||||
return Err(eyre!( |
||||
"don't know which instance, and `--no-interactive` was specified! giving up :c" |
||||
)); |
||||
} |
||||
inquire::prompt_text("Which instance?") |
||||
.map(sanitize_instance) |
||||
.inspect(|inst| { |
||||
INSTANCE_CACHE.set(inst.clone()).expect("is only set once"); |
||||
}) |
||||
.wrap_err("could not prompt for text input") |
||||
} |
||||
} |
||||
|
||||
fn sanitize_instance(inst: String) -> String { |
||||
if inst.starts_with("http://") || inst.starts_with("https://") { |
||||
inst |
||||
} else { |
||||
format!("https://{inst}") |
||||
} |
||||
.trim() |
||||
.trim_end_matches("/") |
||||
.to_owned() |
||||
} |
@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
|
||||
use std::{fmt::Debug, str::FromStr}; |
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc}; |
||||
use color_eyre::eyre::{eyre, OptionExt, Result, WrapErr}; |
||||
use reqwest::{ |
||||
header::{HeaderMap, HeaderValue}, |
||||
Client as RClient, IntoUrl, Method, RequestBuilder, |
||||
}; |
||||
use serde::Deserialize; |
||||
use tracing::{info, instrument, trace, warn}; |
||||
|
||||
use crate::{ |
||||
misc::{format_timedelta, FediSoftware}, |
||||
node_info::{NodeInfo, WellKnownNodeInfo}, |
||||
runtime_info::RuntimeInfo, |
||||
}; |
||||
|
||||
#[derive(Debug)] |
||||
pub struct Client { |
||||
pub client: RClient, |
||||
pub base_url: String, |
||||
pub meta: RuntimeInfo, |
||||
} |
||||
|
||||
impl Client { |
||||
pub fn new(base_url: String) -> Self { |
||||
Self { |
||||
client: RClient::new(), |
||||
base_url, |
||||
meta: RuntimeInfo::init(), |
||||
} |
||||
} |
||||
|
||||
pub fn domain(&self) -> &str { |
||||
self.base_url |
||||
.rsplit_once("//") |
||||
.expect("base_url contains //") |
||||
.1 |
||||
} |
||||
|
||||
#[instrument(level = "trace")] |
||||
pub fn request<U: IntoUrl + Debug>(&self, method: Method, url: U) -> RequestBuilder { |
||||
self.client |
||||
.request(method, url) |
||||
.header("User-Agent", self.meta.user_agent()) |
||||
} |
||||
|
||||
pub fn request_endpoint(&self, method: Method, endpoint: &str) -> RequestBuilder { |
||||
self.request(method, format!("{}{}", &self.base_url, endpoint)) |
||||
} |
||||
|
||||
pub async fn query_nodeinfo(&self) -> Result<FediSoftware> { |
||||
let resp: WellKnownNodeInfo = self |
||||
.get_and_deserialize_response_json( |
||||
self.request_endpoint(Method::GET, "/.well-known/nodeinfo"), |
||||
) |
||||
.await?; |
||||
|
||||
let url = resp |
||||
.links |
||||
.into_iter() |
||||
.find_map(|lnk| match lnk.version() { |
||||
"2.0" | "2.1" => Some(lnk.href), |
||||
_ => None, |
||||
}) |
||||
.ok_or_eyre( |
||||
"no supported nodeinfo version found! supported versions are 2.0 and 2.1", |
||||
)?; |
||||
|
||||
let node_info: NodeInfo = self |
||||
.get_and_deserialize_response_json(self.request(Method::GET, &url)) |
||||
.await?; |
||||
|
||||
Ok(node_info.software.name.into()) |
||||
} |
||||
|
||||
pub async fn honor_rate_limit(headers: HeaderMap) -> Result<()> { |
||||
let limit: Option<usize> = Self::parse_header_via_str(&headers, "x-ratelimit-limit")?; |
||||
let remaining: Option<usize> = |
||||
Self::parse_header_via_str(&headers, "x-ratelimit-remaining")?; |
||||
let reset: Option<DateTime<Utc>> = |
||||
Self::parse_header_via_str(&headers, "x-ratelimit-reset")?; |
||||
let lim = limit.map(|l| l / 10).unwrap_or(1); |
||||
if remaining.is_some_and(|r| r < lim) { |
||||
if let Some(reset_time) = reset { |
||||
let now = Utc::now(); |
||||
let diff = reset_time - now; |
||||
if diff > TimeDelta::zero() { |
||||
info!( |
||||
"rate limit reached! waiting {} until reset", |
||||
format_timedelta(&diff) |
||||
) |
||||
} |
||||
} |
||||
} |
||||
Ok(()) |
||||
} |
||||
|
||||
fn parse_header_via_str<T: FromStr>( |
||||
headers: &HeaderMap, |
||||
header_name: &str, |
||||
) -> Result<Option<T>> { |
||||
headers |
||||
.get(header_name) |
||||
.map(HeaderValue::to_str) |
||||
.transpose() |
||||
.wrap_err("couldn't get string from header value")? |
||||
.map(|lim| lim.parse()) |
||||
.transpose() |
||||
.map_err(|_| eyre!("couldn't parse string")) |
||||
} |
||||
|
||||
pub async fn get_and_deserialize_response_json<O: for<'a> Deserialize<'a>>( |
||||
&self, |
||||
request_builder: RequestBuilder, |
||||
) -> Result<O> { |
||||
let request = request_builder.build().wrap_err("couldn't build request")?; |
||||
let to = request.url().as_str().to_owned(); |
||||
|
||||
trace!(?request, ?to); |
||||
|
||||
let (rate_limit_wait, text_fetch) = self |
||||
.client |
||||
.execute(request) |
||||
.await |
||||
.wrap_err(format!("could not send request to `{to}`")) |
||||
.and_then(|resp| { |
||||
let status = resp.status(); |
||||
let headers = resp.headers().clone(); |
||||
let rate_limit_wait_fut = Self::honor_rate_limit(headers); |
||||
status |
||||
.is_success() |
||||
.then(|| (rate_limit_wait_fut, resp.text())) |
||||
.ok_or_eyre(format!("request returned status `{}`", status)) |
||||
})?; |
||||
let deserialized_res = text_fetch |
||||
.await |
||||
.inspect(|text| trace!(?text)) |
||||
.wrap_err("couldn't get response body as text") |
||||
.and_then(|text| { |
||||
serde_json::from_str(&text).wrap_err("couldn't deserialize json response") |
||||
}); |
||||
|
||||
rate_limit_wait |
||||
.await |
||||
.inspect_err(|e| warn!("failed waiting for rate limit: {e}")) |
||||
.ok(); |
||||
|
||||
deserialized_res |
||||
} |
||||
} |
@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
|
||||
use serde::{Deserialize, Serialize}; |
||||
|
||||
#[derive(Deserialize)] |
||||
#[allow(unused)] |
||||
pub struct Token { |
||||
pub access_token: String, |
||||
pub scope: Option<String>, |
||||
pub token_type: String, |
||||
pub created_at: usize, |
||||
} |
||||
|
||||
#[derive(Serialize)] |
||||
pub struct AccessTokenRequest { |
||||
pub redirect_uri: String, |
||||
pub client_id: String, |
||||
pub client_secret: String, |
||||
pub grant_type: String, |
||||
pub code: String, |
||||
pub scope: String, |
||||
} |
||||
|
||||
impl AccessTokenRequest { |
||||
pub fn from_client_id_secret_and_auth_token( |
||||
client_id: String, |
||||
client_secret: String, |
||||
code: String, |
||||
) -> Self { |
||||
Self { |
||||
redirect_uri: "urn:ietf:wg:oauth:2.0:oob".into(), |
||||
client_id, |
||||
client_secret, |
||||
grant_type: "authorization_code".into(), |
||||
code, |
||||
scope: "read".into(), |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,239 @@
@@ -0,0 +1,239 @@
|
||||
use std::{fmt::Display, fs, future::Future, path::PathBuf}; |
||||
|
||||
use color_eyre::eyre::{eyre, Result, WrapErr}; |
||||
use reqwest::{Method, RequestBuilder}; |
||||
use tracing::{debug, error, info, trace}; |
||||
|
||||
use crate::{ |
||||
args, |
||||
client::Client, |
||||
gts, mastodon, |
||||
misc::{CustomEmoji, FediSoftware}, |
||||
}; |
||||
|
||||
#[derive(Debug)] |
||||
pub struct EmojiFetcher { |
||||
output_dir: PathBuf, |
||||
client: Client, |
||||
} |
||||
|
||||
#[derive(Clone, Debug)] |
||||
struct FetchableEmoji { |
||||
name: String, |
||||
url: String, |
||||
category: Option<String>, |
||||
fetched: bool, |
||||
} |
||||
|
||||
impl From<CustomEmoji> for FetchableEmoji { |
||||
fn from(ce: CustomEmoji) -> Self { |
||||
Self { |
||||
name: ce.shortcode, |
||||
url: ce.url, |
||||
category: ce.category, |
||||
fetched: false, |
||||
} |
||||
} |
||||
} |
||||
|
||||
impl Display for FetchableEmoji { |
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
||||
write!( |
||||
f, |
||||
"`{}{}`", |
||||
self.category |
||||
.as_ref() |
||||
.map(|c| format!("{c}/")) |
||||
.unwrap_or("".into()), |
||||
self.name |
||||
) |
||||
} |
||||
} |
||||
|
||||
impl EmojiFetcher { |
||||
pub fn new() -> Result<Self> { |
||||
let args = args::get(); |
||||
trace!(?args); |
||||
let base_url = args.try_get_instance()?; |
||||
let default_output_dir = PathBuf::from( |
||||
base_url |
||||
.split_once("//") |
||||
.expect("base_url always contains a `//`") |
||||
.1, |
||||
); |
||||
|
||||
let this = Self { |
||||
output_dir: args.output_dir.clone().unwrap_or_else(|| { |
||||
info!("outputting to `{}`", default_output_dir.display()); |
||||
default_output_dir |
||||
}), |
||||
client: Client::new(base_url), |
||||
}; |
||||
|
||||
info!("fedi_emoji_downloader initialized"); |
||||
trace!(?this); |
||||
|
||||
Ok(this) |
||||
} |
||||
|
||||
async fn needs_interactive<T>( |
||||
&self, |
||||
op_name: &str, |
||||
op: impl Future<Output = Result<T>>, |
||||
) -> Result<T> { |
||||
if args::get().no_interactive { |
||||
error!("`{op_name}` is only possible in interactive mode!"); |
||||
return Err(eyre!("exiting due to `--no-interactive`")); |
||||
} |
||||
|
||||
op.await |
||||
} |
||||
|
||||
pub async fn fetch(&self) -> Result<()> { |
||||
let software = self |
||||
.automatically_get_target_software() |
||||
.await |
||||
.unwrap_or_default(); |
||||
|
||||
debug!("detected software as `{}`", &software); |
||||
|
||||
self.fetch_appropriate(software).await?; |
||||
|
||||
info!( |
||||
"done! you can find your emojis in the `{}` directory", |
||||
self.output_dir.display() |
||||
); |
||||
|
||||
Ok(()) |
||||
} |
||||
|
||||
async fn fetch_appropriate(&self, software: FediSoftware) -> Result<()> { |
||||
match software { |
||||
FediSoftware::Mastodon => self.try_fetch_mastodon().await, |
||||
FediSoftware::Gotosocial => { |
||||
self.needs_interactive("logging into gotosocial", self.try_fetch_gts()) |
||||
.await |
||||
} |
||||
FediSoftware::Other(other) => { |
||||
error!("unsupported fedi software: `{other}`"); |
||||
Ok(()) |
||||
} |
||||
FediSoftware::Unknown => { |
||||
self.needs_interactive( |
||||
"asking for software choice after auto-detect fails", |
||||
self.ask_target_and_try_fetch(), |
||||
) |
||||
.await |
||||
} |
||||
} |
||||
} |
||||
|
||||
async fn automatically_get_target_software(&self) -> Result<FediSoftware> { |
||||
debug!("detecting software"); |
||||
self.client.query_nodeinfo().await |
||||
} |
||||
|
||||
async fn try_fetch_mastodon(&self) -> Result<()> { |
||||
info!("trying to fetch emojis from mastodon"); |
||||
info!("trying without authentication"); |
||||
let unauth_res = self.do_fetch(|rb| rb).await; |
||||
if unauth_res.is_ok() { |
||||
info!("successfully fetched from mastodon without authentication"); |
||||
return Ok(()); |
||||
} |
||||
info!("seems like authentication is needed; asking necessary info"); |
||||
let access_token = self |
||||
.needs_interactive( |
||||
"logging into mastodon", |
||||
mastodon::auth_interactive(&self.client), |
||||
) |
||||
.await?; |
||||
|
||||
mastodon::verify_login(&self.client, &access_token).await?; |
||||
|
||||
self.do_fetch(|rb| rb.bearer_auth(&access_token)).await?; |
||||
|
||||
mastodon::invalidate_token(&self.client, &access_token).await |
||||
} |
||||
|
||||
async fn try_fetch_gts(&self) -> Result<()> { |
||||
info!("trying to fetch from gotosocial"); |
||||
let access_token = gts::auth_interactive(&self.client).await?; |
||||
gts::verify_login(&self.client, &access_token).await?; |
||||
|
||||
self.do_fetch(|rb| rb.bearer_auth(&access_token)).await?; |
||||
|
||||
Ok(()) |
||||
} |
||||
|
||||
async fn ask_target_and_try_fetch(&self) -> Result<()> { |
||||
info!("Could not auto-detect which software the instance uses."); |
||||
let options = vec![ |
||||
FediSoftware::Mastodon, |
||||
FediSoftware::Gotosocial, |
||||
FediSoftware::Other("".into()), |
||||
]; |
||||
let answer = inquire::Select::new("Please select the instance software:", options) |
||||
.prompt() |
||||
.wrap_err("failed asking for instance software selection")?; |
||||
|
||||
Box::pin(self.fetch_appropriate(answer)).await |
||||
} |
||||
|
||||
async fn do_fetch(&self, auth: impl Fn(RequestBuilder) -> RequestBuilder) -> Result<()> { |
||||
let mut emoji_list: Vec<FetchableEmoji> = self |
||||
.client |
||||
.get_and_deserialize_response_json::<Vec<CustomEmoji>>(auth( |
||||
self.client |
||||
.request_endpoint(Method::GET, "/api/v1/custom_emojis"), |
||||
)) |
||||
.await? |
||||
.into_iter() |
||||
.map(FetchableEmoji::from) |
||||
.collect(); |
||||
|
||||
info!("fetching {} emojis", emoji_list.len()); |
||||
|
||||
for emoji in emoji_list.iter_mut().filter(|e| !e.fetched) { |
||||
info!("fetching {emoji}"); |
||||
|
||||
let resp = self |
||||
.client |
||||
.request(Method::GET, &emoji.url) |
||||
.send() |
||||
.await |
||||
.wrap_err("error fetching emoji")?; |
||||
|
||||
let bytes = resp |
||||
.bytes() |
||||
.await |
||||
.wrap_err("error getting emoji file content")?; |
||||
|
||||
let file_ext = emoji |
||||
.url |
||||
.rsplit_once(".") |
||||
.expect("file url should contain a `.`") |
||||
.1; |
||||
|
||||
let file_name = emoji.name.clone() + "." + file_ext; |
||||
|
||||
emoji.fetched = true; |
||||
|
||||
let out_dir = if let Some(cat) = emoji.category.as_ref() { |
||||
self.output_dir.join(cat) |
||||
} else { |
||||
self.output_dir.clone() |
||||
}; |
||||
|
||||
let outfile = out_dir.join(file_name); |
||||
|
||||
fs::create_dir_all(out_dir).wrap_err("could not create output directory")?; |
||||
|
||||
fs::write(outfile, bytes).wrap_err("could not write output file")?; |
||||
} |
||||
|
||||
assert!(emoji_list.iter().all(|e| e.fetched)); |
||||
|
||||
Ok(()) |
||||
} |
||||
} |
@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
|
||||
use color_eyre::eyre::{Result, WrapErr}; |
||||
use inquire::prompt_text; |
||||
use reqwest::Method; |
||||
use serde::{Deserialize, Serialize}; |
||||
use serde_json::{Map, Value}; |
||||
use tracing::{info, trace, warn}; |
||||
|
||||
use crate::{ |
||||
client::Client, |
||||
common::{AccessTokenRequest, Token}, |
||||
misc::CustomEmoji, |
||||
}; |
||||
|
||||
#[derive(Serialize)] |
||||
pub struct AppCreation { |
||||
pub client_name: String, |
||||
pub redirect_uris: String, |
||||
pub scopes: Option<String>, |
||||
pub website: Option<String>, |
||||
} |
||||
|
||||
impl AppCreation { |
||||
pub fn with_client_name(client_name: String) -> Self { |
||||
Self { |
||||
client_name, |
||||
redirect_uris: "urn:ietf:wg:oauth:2.0:oob".into(), |
||||
scopes: Some("read".into()), |
||||
website: None, |
||||
} |
||||
} |
||||
} |
||||
|
||||
#[derive(Debug, Deserialize)] |
||||
#[allow(unused)] |
||||
pub struct Application { |
||||
pub client_id: String, |
||||
pub client_secret: String, |
||||
pub id: Option<String>, |
||||
pub name: String, |
||||
pub redirect_uri: Option<String>, |
||||
pub vapid_key: Option<String>, |
||||
pub website: Option<String>, |
||||
} |
||||
|
||||
type JsonObject = Map<String, Value>; |
||||
|
||||
#[derive(Deserialize)] |
||||
#[allow(unused)] |
||||
struct User { |
||||
id: String, |
||||
username: String, |
||||
acct: String, |
||||
url: String, |
||||
display_name: String, |
||||
theme: String, |
||||
suspended: Option<bool>, |
||||
statuses_count: usize, |
||||
source: JsonObject, |
||||
roles: Vec<JsonObject>, |
||||
role: JsonObject, |
||||
note: String, |
||||
moved: Option<JsonObject>, |
||||
locked: bool, |
||||
last_status_at: String, |
||||
hide_collections: Option<bool>, |
||||
header_static: String, |
||||
header_media_id: String, |
||||
header_description: Option<String>, |
||||
header: String, |
||||
following_count: usize, |
||||
followers_count: usize, |
||||
fields: Vec<JsonObject>, |
||||
enable_rss: Option<bool>, |
||||
emojis: Vec<CustomEmoji>, |
||||
discoverable: bool, |
||||
custom_css: Option<String>, |
||||
created_at: String, |
||||
bot: bool, |
||||
avatar_static: String, |
||||
avatar_media_id: String, |
||||
avatar_description: Option<String>, |
||||
avatar: String, |
||||
} |
||||
|
||||
pub async fn auth_interactive(client: &Client) -> Result<String> { |
||||
trace!("authenticating on gts"); |
||||
let body = serde_json::to_string(&AppCreation::with_client_name(crate::APP_NAME.to_string())) |
||||
.expect("AppCreation can be serialized to json"); |
||||
|
||||
trace!(?body); |
||||
|
||||
let created_application: Application = client |
||||
.get_and_deserialize_response_json( |
||||
client |
||||
.request_endpoint(Method::POST, "/api/v1/apps") |
||||
.header("Content-Type", "application/json") |
||||
.body(body), |
||||
) |
||||
.await?; |
||||
|
||||
trace!(?created_application); |
||||
|
||||
let url = format!("{}/oauth/authorize?client_id={}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read", &client.base_url, &created_application.client_id); |
||||
|
||||
let auth_code = prompt_text(format!("logging into gts: {}", url)) |
||||
.wrap_err("couldn't read auth token from stdin")?; |
||||
|
||||
let body = serde_json::to_string(&AccessTokenRequest::from_client_id_secret_and_auth_token( |
||||
created_application.client_id, |
||||
created_application.client_secret, |
||||
auth_code, |
||||
)) |
||||
.expect("AccessTokenRequest can be serialized to json"); |
||||
|
||||
let token_created: Token = client |
||||
.get_and_deserialize_response_json( |
||||
client |
||||
.request_endpoint(Method::POST, "/oauth/token") |
||||
.header("Content-Type", "application/json") |
||||
.body(body), |
||||
) |
||||
.await?; |
||||
|
||||
Ok(token_created.access_token) |
||||
} |
||||
|
||||
pub async fn verify_login(client: &Client, token: &str) -> Result<()> { |
||||
let user: User = client |
||||
.get_and_deserialize_response_json( |
||||
client |
||||
.request_endpoint(Method::GET, "/api/v1/accounts/verify_credentials") |
||||
.bearer_auth(token), |
||||
) |
||||
.await?; |
||||
|
||||
info!("logged in as `@{}@{}`", user.username, client.domain()); |
||||
|
||||
warn!( |
||||
"{} {}", |
||||
"currently, gts does not have a way of programmatically revoking the token that was generated.", |
||||
"it will not be saved, but you may want to revoke it." |
||||
); |
||||
|
||||
Ok(()) |
||||
} |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
use std::{env, str::FromStr}; |
||||
|
||||
use color_eyre::eyre::Result; |
||||
use gay_tracing_formatter::GayFormattingLayer; |
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer}; |
||||
|
||||
pub(crate) fn setup() -> Result<()> { |
||||
let level = format!( |
||||
"warn,fedi_emoji_downloader={}", |
||||
if cfg!(debug_assertions) { |
||||
"debug" |
||||
} else { |
||||
"info" |
||||
} |
||||
); |
||||
|
||||
let level = if let Ok(filter_string) = env::var("RUST_LOG") { |
||||
EnvFilter::from_str(&filter_string)?.boxed() |
||||
} else { |
||||
EnvFilter::from_str(&level)?.boxed() |
||||
}; |
||||
|
||||
let the_gay = GayFormattingLayer::with_crate_name(Some(crate::APP_NAME.to_owned())); |
||||
|
||||
tracing_subscriber::registry() |
||||
.with(the_gay) |
||||
.with(level) |
||||
.init(); |
||||
|
||||
Ok(()) |
||||
} |
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
mod args; |
||||
mod client; |
||||
mod common; |
||||
mod fetcher; |
||||
mod gts; |
||||
mod logging; |
||||
mod mastodon; |
||||
mod misc; |
||||
mod node_info; |
||||
mod runtime_info; |
||||
|
||||
pub const APP_NAME: &str = env!("CARGO_PKG_NAME"); |
||||
|
||||
#[tokio::main] |
||||
async fn main() -> color_eyre::eyre::Result<()> { |
||||
color_eyre::install()?; |
||||
logging::setup()?; |
||||
|
||||
fetcher::EmojiFetcher::new()?.fetch().await |
||||
} |
@ -0,0 +1,180 @@
@@ -0,0 +1,180 @@
|
||||
use std::sync::OnceLock; |
||||
|
||||
use color_eyre::eyre::{eyre, Result, WrapErr}; |
||||
use inquire::prompt_text; |
||||
use reqwest::Method; |
||||
use serde::{Deserialize, Serialize}; |
||||
use serde_json::{Map, Value}; |
||||
use tracing::{error, info}; |
||||
|
||||
use crate::{ |
||||
client::Client, |
||||
common::{AccessTokenRequest, Token}, |
||||
misc::CustomEmoji, |
||||
}; |
||||
|
||||
#[derive(Serialize)] |
||||
pub struct AppCreation { |
||||
pub client_name: String, |
||||
pub redirect_uris: Vec<String>, |
||||
pub scopes: Option<String>, |
||||
pub website: Option<String>, |
||||
} |
||||
|
||||
impl AppCreation { |
||||
pub fn with_client_name(client_name: String) -> Self { |
||||
Self { |
||||
client_name, |
||||
redirect_uris: vec!["urn:ietf:wg:oauth:2.0:oob".into()], |
||||
scopes: Some("read".into()), |
||||
website: None, |
||||
} |
||||
} |
||||
} |
||||
|
||||
#[derive(Debug, Deserialize)] |
||||
#[allow(unused)] |
||||
struct CredentialApplication { |
||||
name: String, |
||||
website: Option<String>, |
||||
scopes: Vec<String>, |
||||
redirect_uris: Vec<String>, |
||||
redirect_uri: Option<String>, |
||||
vapid_key: Option<String>, |
||||
|
||||
client_id: String, |
||||
client_secret: String, |
||||
client_secret_expires_at: Option<String>, |
||||
} |
||||
|
||||
static APP: OnceLock<CredentialApplication> = OnceLock::new(); |
||||
|
||||
#[derive(Deserialize)] |
||||
#[allow(unused)] |
||||
struct User { |
||||
id: String, |
||||
username: String, |
||||
acct: String, |
||||
url: String, |
||||
display_name: String, |
||||
note: String, |
||||
avatar: String, |
||||
avatar_static: String, |
||||
header: String, |
||||
header_static: String, |
||||
locked: bool, |
||||
fields: Vec<Map<String, Value>>, |
||||
emojis: Vec<CustomEmoji>, |
||||
bot: bool, |
||||
group: bool, |
||||
noindex: Option<bool>, |
||||
moved: Option<Option<Box<User>>>, |
||||
suspended: Option<bool>, |
||||
limited: Option<bool>, |
||||
created_at: String, |
||||
last_status_at: Option<String>, |
||||
statuses_count: usize, |
||||
followers_count: usize, |
||||
following_count: usize, |
||||
} |
||||
|
||||
#[derive(Serialize)] |
||||
struct RevokeToken { |
||||
client_id: String, |
||||
client_secret: String, |
||||
token: String, |
||||
} |
||||
|
||||
pub async fn auth_interactive(client: &Client) -> Result<String> { |
||||
let body = serde_json::to_string(&AppCreation::with_client_name(crate::APP_NAME.to_string())) |
||||
.expect("AppCreation can be serialized to json"); |
||||
|
||||
let app: CredentialApplication = client |
||||
.get_and_deserialize_response_json( |
||||
client |
||||
.request_endpoint(Method::POST, "/api/v1/apps") |
||||
.header("Content-Type", "application/json") |
||||
.body(body), |
||||
) |
||||
.await?; |
||||
|
||||
APP.set(app) |
||||
.expect("mastodon::auth_interactive is only set once"); |
||||
|
||||
let app = APP.get().expect("APP is set from the line above"); |
||||
|
||||
let url = format!("{}/oauth/authorize?client_id={}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read", &client.base_url, &app.client_id); |
||||
info!("about to log into mastodon"); |
||||
info!("open this url: {url}"); |
||||
|
||||
let auth_token = prompt_text("paste the token from your instance web page here: ") |
||||
.wrap_err("couldn't read auth token from stdin")?; |
||||
|
||||
let body = serde_json::to_string(&AccessTokenRequest::from_client_id_secret_and_auth_token( |
||||
app.client_id.clone(), |
||||
app.client_secret.clone(), |
||||
auth_token, |
||||
)) |
||||
.expect("AccessTokenRequest can be serialized to json"); |
||||
|
||||
let token_created: Token = client |
||||
.get_and_deserialize_response_json( |
||||
client |
||||
.request_endpoint(Method::POST, "/oauth/token") |
||||
.header("Content-Type", "application/json") |
||||
.body(body), |
||||
) |
||||
.await?; |
||||
|
||||
Ok(token_created.access_token) |
||||
} |
||||
|
||||
pub async fn verify_login(client: &Client, token: &str) -> Result<()> { |
||||
let user: User = client |
||||
.get_and_deserialize_response_json( |
||||
client |
||||
.request_endpoint(Method::GET, "/api/v1/accounts/verify_credentials") |
||||
.bearer_auth(token), |
||||
) |
||||
.await?; |
||||
|
||||
info!("logged in as `@{}@{}`", user.username, client.domain()); |
||||
|
||||
Ok(()) |
||||
} |
||||
|
||||
pub async fn invalidate_token(client: &Client, token: &str) -> Result<()> { |
||||
let app = APP.get().expect("APP is initialized"); |
||||
|
||||
client |
||||
.request_endpoint(Method::POST, "/oauth/revoke") |
||||
.bearer_auth(token) |
||||
.body( |
||||
serde_json::to_string(&RevokeToken { |
||||
client_id: app.client_id.clone(), |
||||
client_secret: app.client_secret.clone(), |
||||
token: token.to_owned(), |
||||
}) |
||||
.expect("RevokeToken is serializeable as json"), |
||||
) |
||||
.send() |
||||
.await |
||||
.wrap_err("revoke request failed, see above") |
||||
.and_then(|resp| { |
||||
resp.status() |
||||
.is_success() |
||||
.then_some(()) |
||||
.ok_or_else(|| eyre!("revoke failed, see above")) |
||||
}) |
||||
.inspect_err(|_| { |
||||
error!("failed revoking the mastodon access token!"); |
||||
info!( |
||||
"you can revoke it manually at {}/oauth/authorized_applications", |
||||
&client.base_url |
||||
); |
||||
})?; |
||||
|
||||
info!("oauth token successfully revoked"); |
||||
|
||||
Ok(()) |
||||
} |
@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
|
||||
use std::fmt::Display; |
||||
|
||||
use chrono::TimeDelta; |
||||
use serde::Deserialize; |
||||
|
||||
#[derive(Clone, Debug, Default)] |
||||
pub enum FediSoftware { |
||||
Mastodon, |
||||
Gotosocial, |
||||
Other(String), |
||||
#[default] |
||||
Unknown, |
||||
} |
||||
|
||||
impl<S> From<S> for FediSoftware |
||||
where |
||||
S: AsRef<str>, |
||||
{ |
||||
fn from(value: S) -> Self { |
||||
match value.as_ref() { |
||||
"mastodon" => FediSoftware::Mastodon, |
||||
"gotosocial" => FediSoftware::Gotosocial, |
||||
"" => FediSoftware::Unknown, |
||||
other => FediSoftware::Other(other.to_owned()), |
||||
} |
||||
} |
||||
} |
||||
|
||||
impl Display for FediSoftware { |
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
||||
match self { |
||||
FediSoftware::Mastodon => write!(f, "Mastodon"), |
||||
FediSoftware::Gotosocial => write!(f, "Gotosocial"), |
||||
FediSoftware::Other(_) => write!(f, "Other Software"), |
||||
FediSoftware::Unknown => write!(f, "Unknown Software"), |
||||
} |
||||
} |
||||
} |
||||
|
||||
pub fn format_timedelta(delta: &TimeDelta) -> String { |
||||
let mut out = String::new(); |
||||
let days = delta.num_days(); |
||||
let hours = delta.num_hours() % 24; |
||||
let minutes = delta.num_minutes() % 60; |
||||
let seconds = delta.num_seconds() % 60; |
||||
if days > 0 { |
||||
out.push_str(&format!("{days} days, ")); |
||||
} |
||||
if hours > 0 || days > 0 { |
||||
out.push_str(&format!("{hours} hours, ")); |
||||
} |
||||
if minutes > 0 || hours > 0 || days > 0 { |
||||
out.push_str(&format!("{minutes} minutes, ")); |
||||
} |
||||
if seconds > 0 || minutes > 0 || hours > 0 || days > 0 { |
||||
out.push_str(&format!( |
||||
"{}{seconds} seconds", |
||||
if minutes > 0 || hours > 0 || days > 0 { |
||||
"and " |
||||
} else { |
||||
"" |
||||
} |
||||
)) |
||||
} |
||||
|
||||
out |
||||
} |
||||
|
||||
#[derive(Deserialize)] |
||||
#[allow(unused)] |
||||
pub struct CustomEmoji { |
||||
pub shortcode: String, |
||||
pub url: String, |
||||
pub static_url: String, |
||||
pub visible_in_picker: bool, |
||||
pub category: Option<String>, |
||||
} |
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
use serde::Deserialize; |
||||
|
||||
#[derive(Debug, Deserialize)] |
||||
pub struct WellKnownNodeInfo { |
||||
pub links: Vec<WKNIEntry>, |
||||
} |
||||
|
||||
#[derive(Debug, Deserialize)] |
||||
pub struct WKNIEntry { |
||||
pub rel: String, |
||||
pub href: String, |
||||
} |
||||
|
||||
impl WKNIEntry { |
||||
pub fn version(&self) -> &str { |
||||
self.rel.rsplit_once("/").map(|(_, ver)| ver).unwrap_or("") |
||||
} |
||||
} |
||||
|
||||
#[derive(Deserialize)] |
||||
#[serde(rename_all = "camelCase")] |
||||
#[allow(unused)] |
||||
pub struct NodeInfo { |
||||
pub version: String, |
||||
pub software: NISoftware, |
||||
pub protocols: Vec<String>, |
||||
pub services: NIServices, |
||||
pub open_registrations: bool, |
||||
pub usage: NIUsage, |
||||
pub metadata: serde_json::Value, |
||||
} |
||||
|
||||
#[derive(Deserialize)] |
||||
#[allow(unused)] |
||||
pub struct NISoftware { |
||||
pub name: String, |
||||
pub version: String, |
||||
pub repository: Option<String>, |
||||
pub homepage: Option<String>, |
||||
} |
||||
|
||||
#[derive(Deserialize)] |
||||
#[allow(unused)] |
||||
pub struct NIServices { |
||||
pub inbound: Vec<String>, |
||||
pub outbound: Vec<String>, |
||||
} |
||||
|
||||
#[derive(Deserialize)] |
||||
#[serde(rename_all = "camelCase")] |
||||
#[allow(unused)] |
||||
pub struct NIUsage { |
||||
pub users: NIUsers, |
||||
pub local_posts: Option<usize>, |
||||
pub local_comments: Option<usize>, |
||||
} |
||||
|
||||
#[derive(Deserialize)] |
||||
#[serde(rename_all = "camelCase")] |
||||
#[allow(unused)] |
||||
pub struct NIUsers { |
||||
pub total: Option<usize>, |
||||
pub active_halfyear: Option<usize>, |
||||
pub active_month: Option<usize>, |
||||
} |
@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
|
||||
use std::{ |
||||
env, |
||||
io::{self, IsTerminal}, |
||||
sync::OnceLock, |
||||
}; |
||||
|
||||
use sysinfo::System; |
||||
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); |
||||
const XENUA_VERSION: &str = "13.1"; |
||||
const RUSTC_VERSION: &str = env!("FED_RUSTC_VER"); |
||||
|
||||
#[derive(Debug)] |
||||
pub struct RuntimeInfo { |
||||
os_name: String, |
||||
os_version: Option<String>, |
||||
arch: String, |
||||
term: Option<String>, |
||||
hostname: Option<String>, |
||||
} |
||||
|
||||
impl RuntimeInfo { |
||||
pub fn init() -> Self { |
||||
let os_name = sysinfo::IS_SUPPORTED_SYSTEM |
||||
.then(System::name) |
||||
.flatten() |
||||
.unwrap_or(env::consts::OS.to_string()); |
||||
|
||||
let term = io::stdin().is_terminal().then(Self::_term_string).flatten(); |
||||
|
||||
let arch = sysinfo::IS_SUPPORTED_SYSTEM |
||||
.then(System::cpu_arch) |
||||
.flatten() |
||||
.unwrap_or(env::consts::ARCH.to_string()); |
||||
|
||||
let os_version = sysinfo::IS_SUPPORTED_SYSTEM |
||||
.then(System::os_version) |
||||
.flatten() |
||||
.filter(|osv| osv != "rolling") |
||||
.or_else(|| { |
||||
sysinfo::IS_SUPPORTED_SYSTEM |
||||
.then(System::kernel_version) |
||||
.flatten() |
||||
}); |
||||
|
||||
let hostname = sysinfo::IS_SUPPORTED_SYSTEM |
||||
.then(System::host_name) |
||||
.flatten(); |
||||
|
||||
Self { |
||||
os_name, |
||||
term, |
||||
arch, |
||||
os_version, |
||||
hostname, |
||||
} |
||||
} |
||||
|
||||
pub fn user_agent(&self) -> &str { |
||||
static CACHE: OnceLock<String> = OnceLock::new(); |
||||
CACHE.get_or_init(|| { |
||||
format!( |
||||
"xenua/{xenua_ver} ({os}{os_ver}{hostname} {arch}{term}) rustc/{rustc_ver} {pkg_name}/{fed_ver}", |
||||
os = &self.os_name, |
||||
arch = &self.arch, |
||||
os_ver = self.os_version.as_ref().map(|v| format!(" {v}")).unwrap_or_default(), |
||||
term = self |
||||
.term |
||||
.as_ref() |
||||
.map(|t| format!("; {t}")) |
||||
.unwrap_or_default(), |
||||
hostname = self.hostname.as_ref().map(|h| format!(" \"{h}\"")).unwrap_or_default(), |
||||
fed_ver = APP_VERSION, |
||||
pkg_name = crate::APP_NAME, |
||||
rustc_ver = RUSTC_VERSION, |
||||
xenua_ver = XENUA_VERSION, |
||||
) |
||||
}) |
||||
} |
||||
|
||||
#[cfg(unix)] |
||||
fn _term_string() -> Option<String> { |
||||
let tdetect = term_detect::get_terminal().map(|t| t.0).ok(); |
||||
let shell = env::var("SHELL").ok().and_then(|v| { |
||||
v.rsplit_once("/") |
||||
.map(|(_, shell_bin_name)| shell_bin_name.to_owned()) |
||||
}); |
||||
|
||||
match (tdetect, shell) { |
||||
(None, None) => None, |
||||
(None, Some(s)) => Some(s), |
||||
(Some(t), None) => Some(t), |
||||
(Some(t), Some(s)) => Some(format!("{t} / {s}")), |
||||
} |
||||
} |
||||
|
||||
#[cfg(windows)] |
||||
fn _term_string() -> Option<String> { |
||||
Some("windows_terminal".into()) |
||||
} |
||||
|
||||
#[cfg(not(any(unix, windows)))] |
||||
fn _term_string() -> Option<String> { |
||||
None |
||||
} |
||||
} |
Loading…
Reference in new issue