use std::net::SocketAddr; use actix_web::{ body::BodyStream, error::ErrorInternalServerError, web, App, HttpResponse, HttpServer, }; use clap::Parser; use console_subscriber::ConsoleLayer; use opentelemetry::{ sdk::{propagation::TraceContextPropagator, Resource}, KeyValue, }; use opentelemetry_otlp::WithExportConfig; use reqwest::{redirect::Policy, Client}; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use reqwest_tracing::TracingMiddleware; use tracing_actix_web::TracingLogger; use tracing_error::ErrorLayer; use tracing_subscriber::{ filter::Targets, layer::SubscriberExt, registry::LookupSpan, Layer, Registry, }; use url::Url; #[derive(Debug, Parser)] struct Args { #[clap( short, long, env = "PICTRS_ADMIN__BIND_ADDRESS", default_value = "127.0.0.0:8084" )] bind_address: SocketAddr, #[clap( long, env = "PICTRS_ADMIN__PICTRS_ENDPOINT", default_value = "http://localhost:8080" )] pict_rs_endpoint: Url, #[clap(long, env = "PICTRS_ADMIN__PICTRS_API_KEY")] pict_rs_api_key: String, #[clap(long, env = "PICTRS_ADMIN__OPENTELEMETRY_URL")] opentelemetry_url: Option, #[clap(long, env = "PICTRS_ADMIN__OPENTELEMETRY_EVENT_BUFFER_SIZE")] opentelemetry_event_buffer_size: Option, } #[derive(Clone)] struct PictrsClient { client: ClientWithMiddleware, pict_rs_endpoint: Url, pict_rs_api_key: String, } #[derive(Clone, Copy, Debug, serde::Deserialize)] enum OkMessage { #[serde(rename = "ok")] Ok, } #[derive(Clone, Debug, serde::Deserialize)] struct PictrsDetails { width: u16, height: u16, frames: Option, content_type: String, created_at: String, } #[derive(Clone, Debug, serde::Deserialize)] struct PictrsHash { hex: String, aliases: Vec, details: Option, } #[derive(Clone, Debug, serde::Deserialize)] pub struct PictrsPage { #[allow(dead_code)] limit: usize, current: Option, prev: Option, next: Option, hashes: Vec, } #[derive(Clone, Debug, serde::Deserialize)] #[serde(untagged)] enum PageResponse { Ok { #[allow(dead_code)] msg: OkMessage, page: PictrsPage, }, Err { msg: String, }, } #[derive(Debug, serde::Serialize)] struct AliasQuery<'a> { alias: &'a str, } impl PictrsHash { fn media_link(&self) -> Option { self.aliases.first().map(|alias| format!("/image/{alias}")) } fn video_type(&self) -> Option { self.details.as_ref().and_then(|d| { if d.content_type.starts_with("video") { Some(d.content_type.clone()) } else { None } }) } } impl PictrsPage { fn prev_link(&self) -> Option { self.prev.as_ref().map(|slug| format!("/?slug={slug}")) } fn next_link(&self) -> Option { self.next.as_ref().map(|slug| format!("/?slug={slug}")) } fn purge_link(&self, hash: &PictrsHash) -> Option { hash.aliases.first().map(|alias| { if let Some(slug) = &self.current { format!("/purge/{alias}?slug={slug}") } else { format!("/purge/{alias}") } }) } } impl PictrsClient { async fn page(&self, slug: Option) -> Result { let mut url = self.pict_rs_endpoint.clone(); url.set_path("/internal/hashes"); let response = self .client .get(url.as_str()) .header("x-api-token", &self.pict_rs_api_key) .query(&PageQuery { slug }) .send() .await?; response.json().await.map_err(From::from) } async fn purge(&self, alias: &str) -> Result<(), reqwest_middleware::Error> { let mut url = self.pict_rs_endpoint.clone(); url.set_path("/internal/purge"); let _ = self .client .post(url.as_str()) .header("x-api-token", &self.pict_rs_api_key) .query(&AliasQuery { alias }) .send() .await?; Ok(()) } async fn proxy_image(&self, alias: &str) -> Result { let mut url = self.pict_rs_endpoint.clone(); url.set_path(&format!("/image/original/{alias}")); let response = self.client.get(url.as_str()).send().await?; let mut client_res = HttpResponse::build(response.status()); for (name, value) in response .headers() .iter() .filter(|(h, _)| *h != "connection") { client_res.insert_header((name.clone(), value.clone())); } Ok(client_res.body(BodyStream::new(response.bytes_stream()))) } } #[derive(Debug, serde::Deserialize, serde::Serialize)] struct PageQuery { slug: Option, } async fn index( web::Query(PageQuery { slug }): web::Query, client: web::Data, ) -> Result { let page = client.page(slug).await.map_err(ErrorInternalServerError)?; let page = match page { PageResponse::Ok { page, .. } => page, PageResponse::Err { msg } => return Err(ErrorInternalServerError(msg)), }; let mut buf = Vec::new(); templates::index_html(&mut buf, &page).map_err(ErrorInternalServerError)?; let body = minify_html::minify(&buf, &minify_html::Cfg::spec_compliant()); Ok(HttpResponse::Ok().content_type("text/html").body(body)) } async fn image( alias: web::Path, client: web::Data, ) -> Result { client .proxy_image(&alias) .await .map_err(ErrorInternalServerError) } #[derive(Debug, serde::Deserialize)] struct ConfirmQuery { confirm: Option, slug: Option, } async fn purge( alias: web::Path, client: web::Data, web::Query(ConfirmQuery { confirm, slug }): web::Query, ) -> Result { let return_link = slug .as_ref() .map(|s| format!("/?slug={s}")) .unwrap_or_else(|| String::from("/")); if confirm.is_some() { client .purge(&alias) .await .map_err(ErrorInternalServerError)?; return Ok(HttpResponse::SeeOther() .insert_header(("location", return_link)) .finish()); } let purge_link = slug .map(|s| format!("/purge/{alias}?&slug={s}&confirm=1")) .unwrap_or_else(|| format!("/purge/{alias}?confirm=1")); let image_link = format!("/image/{alias}"); let mut buf = Vec::new(); templates::purge_html(&mut buf, &image_link, &purge_link, &return_link) .map_err(ErrorInternalServerError)?; let body = minify_html::minify(&buf, &minify_html::Cfg::spec_compliant()); Ok(HttpResponse::Ok().content_type("text/html").body(body)) } async fn serve_static(name: web::Path) -> HttpResponse { if let Some(data) = templates::statics::StaticFile::get(&name) { HttpResponse::Ok().body(data.content) } else { HttpResponse::NotFound().finish() } } fn init_tracing( service_name: &'static str, opentelemetry_url: Option<&Url>, console_event_buffer_size: Option, ) -> color_eyre::Result<()> { opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new()); tracing_log::LogTracer::init()?; let targets: Targets = std::env::var("RUST_LOG") .unwrap_or_else(|_| "info".into()) .parse()?; let format_layer = tracing_subscriber::fmt::layer().with_filter(targets.clone()); let subscriber = Registry::default() .with(format_layer) .with(ErrorLayer::default()); if let Some(buffer_size) = console_event_buffer_size { let console_layer = ConsoleLayer::builder() .with_default_env() .server_addr(([0, 0, 0, 0], 6669)) .event_buffer_capacity(buffer_size) .spawn(); let subscriber = subscriber.with(console_layer); init_subscriber(subscriber, targets, opentelemetry_url, service_name) } else { init_subscriber(subscriber, targets, opentelemetry_url, service_name) } } fn init_subscriber( subscriber: S, targets: Targets, opentelemetry_url: Option<&Url>, service_name: &'static str, ) -> color_eyre::Result<()> where S: SubscriberExt + Send + Sync, for<'a> S: LookupSpan<'a>, { if let Some(url) = opentelemetry_url { let tracer = opentelemetry_otlp::new_pipeline() .tracing() .with_trace_config(opentelemetry::sdk::trace::config().with_resource( Resource::new(vec![KeyValue::new("service.name", service_name)]), )) .with_exporter( opentelemetry_otlp::new_exporter() .tonic() .with_endpoint(url.as_str()), ) .install_batch(opentelemetry::runtime::Tokio)?; let otel_layer = tracing_opentelemetry::layer() .with_tracer(tracer) .with_filter(targets); let subscriber = subscriber.with(otel_layer); tracing::subscriber::set_global_default(subscriber)?; } else { tracing::subscriber::set_global_default(subscriber)?; } Ok(()) } #[actix_web::main] async fn main() -> color_eyre::Result<()> { let Args { bind_address, pict_rs_endpoint, pict_rs_api_key, opentelemetry_url, opentelemetry_event_buffer_size, } = Args::parse(); init_tracing( "pict-rs-admin", opentelemetry_url.as_ref(), opentelemetry_event_buffer_size, )?; let client = Client::builder() .user_agent("pict-rs-admin v0.1.0") .redirect(Policy::none()) .build()?; let client = ClientBuilder::new(client) .with(TracingMiddleware::default()) .build(); let client = PictrsClient { client, pict_rs_endpoint, pict_rs_api_key, }; HttpServer::new(move || { App::new() .wrap(TracingLogger::default()) .app_data(web::Data::new(client.clone())) .route("/", web::get().to(index)) .route("/image/{path}", web::get().to(image)) .route("/purge/{path}", web::get().to(purge)) .route("/static/{path}", web::get().to(serve_static)) }) .bind(bind_address)? .run() .await?; Ok(()) } include!(concat!(env!("OUT_DIR"), "/templates.rs"));