// need this for ructe #![allow(clippy::needless_borrow)] use actix_web::{ body::BodyStream, http::{ header::{CacheControl, CacheDirective, ContentType, LastModified, LOCATION}, StatusCode, }, web, App, HttpRequest, HttpResponse, HttpResponseBuilder, HttpServer, ResponseError, }; use awc::Client; use console_subscriber::ConsoleLayer; use once_cell::sync::Lazy; use opentelemetry::{ sdk::{propagation::TraceContextPropagator, Resource}, KeyValue, }; use opentelemetry_otlp::WithExportConfig; use std::{ io::Cursor, net::SocketAddr, time::{Duration, SystemTime}, }; use structopt::StructOpt; use tracing_actix_web::TracingLogger; use tracing_awc::Tracing; use tracing_error::{ErrorLayer, SpanTrace}; use tracing_log::LogTracer; use tracing_subscriber::{ filter::Targets, fmt::format::FmtSpan, layer::SubscriberExt, Layer, Registry, }; use url::Url; include!(concat!(env!("OUT_DIR"), "/templates.rs")); const HOURS: u32 = 60 * 60; const DAYS: u32 = 24 * HOURS; #[derive(Clone, Debug, StructOpt)] struct Config { #[structopt( short, long, env = "PICTRS_PROXY_ADDR", default_value = "0.0.0.0:8081", help = "The address and port the server binds to" )] addr: SocketAddr, #[structopt( short, long, env = "PICTRS_PROXY_UPSTREAM", default_value = "http://localhost:8080", help = "The url of the upstream pict-rs server" )] upstream: Url, #[structopt( short, long, env = "PICTRS_PROXY_DOMAIN", default_value = "http://localhost:8081", help = "The scheme, domain, and optional port of the pict-rs proxy server" )] domain: Url, #[structopt( short, long, env = "PICTRS_PROXY_OPENTELEMETRY_URL", help = "URL of OpenTelemetry Collector" )] opentelemetry_url: Option, } impl Config { fn upstream_upload_url(&self) -> String { let mut url = self.upstream.clone(); url.set_path("image"); url.to_string() } fn upstream_details_url(&self, name: &str) -> String { let mut url = self.upstream.clone(); url.set_path(&format!("image/details/original/{}", name)); url.to_string() } fn upstream_image_url(&self, name: &str) -> String { let mut url = self.upstream.clone(); url.set_path(&format!("image/original/{}", name)); url.to_string() } fn upstream_thumbnail_url(&self, size: u64, name: &str, filetype: FileType) -> String { let mut url = self.upstream.clone(); url.set_path(&format!("image/process.{}", filetype.as_str())); url.set_query(Some(&format!("src={}&thumbnail={}", name, size))); url.to_string() } fn upstream_delete_url(&self, token: &str, name: &str) -> String { let mut url = self.upstream.clone(); url.set_path(&format!("image/delete/{}/{}", token, name)); url.to_string() } fn image_url(&self, name: &str) -> String { let mut url = self.domain.clone(); url.set_path(&format!("image/{}", name)); url.to_string() } fn thumbnail_url(&self, size: u64, name: &str, filetype: FileType) -> String { let mut url = self.domain.clone(); url.set_path(&format!("thumb/{}/{}/{}", size, filetype.as_str(), name)); url.to_string() } fn view_url(&self, size: Option, name: &str) -> String { let mut url = self.domain.clone(); if let Some(size) = size { url.set_path(&format!("view/{}/{}", size, name)); } else { url.set_path(&format!("view/{}", name)); } url.to_string() } fn thumbnails_url(&self, name: &str) -> String { let mut url = self.domain.clone(); url.set_path("/thumbnails"); url.set_query(Some(&format!("image={}", name))); url.to_string() } fn delete_url(&self, token: &str, name: &str) -> String { let mut url = self.domain.clone(); url.set_path("delete"); url.set_query(Some(&format!("file={}&token={}", name, token))); url.to_string() } fn confirm_delete_url(&self, token: &str, name: &str) -> String { let mut url = self.domain.clone(); url.set_path("delete"); url.set_query(Some(&format!("file={}&token={}&confirm=true", name, token))); url.to_string() } } static CONFIG: Lazy = Lazy::new(Config::from_args); #[derive(Debug, serde::Deserialize)] enum FileType { #[serde(rename = "jpg")] Jpg, #[serde(rename = "webp")] Webp, } impl FileType { fn as_str(&self) -> &'static str { match self { Self::Jpg => "jpg", Self::Webp => "webp", } } } #[derive(Debug, serde::Deserialize)] pub struct Images { msg: String, files: Option>, } impl Images { fn files(&self) -> Option<&[Image]> { self.files.as_ref().map(|v| v.as_ref()) } fn msg(&self) -> &str { &self.msg } fn is_ok(&self) -> bool { self.files().is_some() } fn message(&self) -> &'static str { if self.is_ok() { "Images Uploaded" } else { "Image Upload Failed" } } } #[derive(Debug, serde::Deserialize)] pub struct Details { content_type: String, } #[derive(Debug, serde::Deserialize)] pub struct Image { file: String, delete_token: String, details: Details, } impl Image { fn filename(&self) -> &str { &self.file } fn is_video(&self) -> bool { self.details.content_type.starts_with("video") } fn mime(&self) -> &str { &self.details.content_type } fn link(&self) -> String { CONFIG.image_url(&self.file) } fn thumbnails(&self) -> String { CONFIG.thumbnails_url(&self.file) } fn view(&self, size: Option) -> String { CONFIG.view_url(size, &self.file) } fn thumb(&self, size: u64, filetype: FileType) -> String { CONFIG.thumbnail_url(size, &self.file, filetype) } fn delete(&self) -> String { CONFIG.delete_url(&self.delete_token, &self.file) } fn confirm_delete(&self) -> String { CONFIG.confirm_delete_url(&self.delete_token, &self.file) } } fn statics(file: &str) -> String { format!("/static/{}", file) } #[derive(Debug)] struct Error { context: SpanTrace, kind: ErrorKind, } impl From for Error where ErrorKind: From, { fn from(error: T) -> Self { Error { context: SpanTrace::capture(), kind: error.into(), } } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!(f, "{}", self.kind)?; std::fmt::Display::fmt(&self.context, f) } } impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { self.kind.source() } } impl ResponseError for Error { fn status_code(&self) -> StatusCode { StatusCode::INTERNAL_SERVER_ERROR } fn error_response(&self) -> HttpResponse { match render(HttpResponse::build(self.status_code()), |cursor| { self::templates::error(cursor, &self.kind.to_string()) }) { Ok(res) => res, Err(_) => HttpResponse::build(self.status_code()) .content_type(mime::TEXT_PLAIN.essence_str()) .body(self.kind.to_string()), } } } #[derive(Debug, thiserror::Error)] enum ErrorKind { #[error("{0}")] Io(#[from] std::io::Error), #[error("{0}")] SendRequest(#[from] awc::error::SendRequestError), #[error("{0}")] JsonPayload(#[from] awc::error::JsonPayloadError), } #[tracing::instrument(name = "Upload")] async fn index() -> Result { render(HttpResponse::Ok(), |cursor| { self::templates::index(cursor, "/upload", "images[]") }) } #[tracing::instrument(name = "Upload", skip(req, body, client))] async fn upload( req: HttpRequest, body: web::Payload, client: web::Data, ) -> Result { let client_request = client.request_from(CONFIG.upstream_upload_url(), req.head()); let client_request = if let Some(addr) = req.head().peer_addr { client_request.append_header(("X-Forwarded-For", addr.to_string())) } else { client_request }; let mut res = client_request.send_stream(body).await?; let images = res.json::().await?; render(HttpResponse::build(res.status()), |cursor| { self::templates::images(cursor, images) }) } const THUMBNAIL_SIZES: &[u64] = &[40, 50, 80, 100, 200, 400, 800, 1200]; #[derive(Debug, serde::Deserialize)] struct ThumbnailQuery { image: String, } #[tracing::instrument(name = "Thumbs", skip(client))] async fn thumbs( query: web::Query, client: web::Data, ) -> Result { let file = query.into_inner().image; let url = CONFIG.upstream_details_url(&file); let mut res = client.get(url).send().await?; if res.status() == StatusCode::NOT_FOUND { return Ok(to_404()); } let details: Details = res.json().await?; let image = Image { file, delete_token: String::new(), details, }; render(HttpResponse::Ok(), |cursor| { self::templates::thumbnails(cursor, image, THUMBNAIL_SIZES) }) } #[tracing::instrument(name = "Image", skip(req, client))] async fn image( url: String, req: HttpRequest, client: web::Data, ) -> Result { let client_request = client.request_from(url, req.head()); let client_request = if let Some(addr) = req.head().peer_addr { client_request.insert_header(("X-Forwarded-For", addr.to_string())) } else { client_request }; let res = client_request.no_decompress().send().await?; if res.status() == StatusCode::NOT_FOUND { return Ok(to_404()); } let mut client_res = HttpResponse::build(res.status()); for (name, value) in res.headers().iter().filter(|(h, _)| *h != "connection") { client_res.insert_header((name.clone(), value.clone())); } Ok(client_res.body(BodyStream::new(res))) } #[tracing::instrument(name = "View original", skip(client))] async fn view_original( file: web::Path, client: web::Data, ) -> Result { let file = file.into_inner(); let url = CONFIG.upstream_details_url(&file); let mut res = client.get(url).send().await?; if res.status() == StatusCode::NOT_FOUND { return Ok(to_404()); } let details: Details = res.json().await?; let image = Image { file, delete_token: String::new(), details, }; render(HttpResponse::Ok(), |cursor| { self::templates::view(cursor, image, None) }) } #[tracing::instrument(name = "View", skip(client))] async fn view( parts: web::Path<(u64, String)>, client: web::Data, ) -> Result { let (size, file) = parts.into_inner(); if !valid_thumbnail_size(size) { return Ok(to_404()); } let url = CONFIG.upstream_details_url(&file); let mut res = client.get(url).send().await?; if res.status() == StatusCode::NOT_FOUND { return Ok(to_404()); } let details: Details = res.json().await?; let image = Image { file, delete_token: String::new(), details, }; render(HttpResponse::Ok(), |cursor| { self::templates::view(cursor, image, Some(size)) }) } #[tracing::instrument(name = "Thumbnail", skip(req, client))] async fn thumbnail( parts: web::Path<(u64, FileType, String)>, req: HttpRequest, client: web::Data, ) -> Result { let (size, filetype, file) = parts.into_inner(); if valid_thumbnail_size(size) { let url = CONFIG.upstream_thumbnail_url(size, &file, filetype); return image(url, req, client).await; } Ok(to_404()) } fn valid_thumbnail_size(size: u64) -> bool { THUMBNAIL_SIZES.contains(&size) } #[tracing::instrument(name = "Full resolution", skip(req, client))] async fn full_res( filename: web::Path, req: HttpRequest, client: web::Data, ) -> Result { let url = CONFIG.upstream_image_url(&filename.into_inner()); image(url, req, client).await } #[allow(clippy::async_yields_async)] #[tracing::instrument(name = "Static files")] async fn static_files(filename: web::Path) -> HttpResponse { let filename = filename.into_inner(); if let Some(data) = self::templates::statics::StaticFile::get(&filename) { return HttpResponse::Ok() .insert_header(LastModified(SystemTime::now().into())) .insert_header(CacheControl(vec![ CacheDirective::Public, CacheDirective::MaxAge(365 * DAYS), CacheDirective::Extension("immutable".to_owned(), None), ])) .insert_header(ContentType(data.mime.clone())) .body(data.content); } to_404() } #[derive(Debug, serde::Deserialize)] struct DeleteQuery { token: String, file: String, #[serde(default)] confirm: bool, } #[tracing::instrument(name = "Delete", skip(client))] async fn delete( query: web::Query, client: web::Data, ) -> Result { let DeleteQuery { token, file, confirm, } = query.into_inner(); let url = CONFIG.upstream_details_url(&file); let mut res = client.get(url).send().await?; if res.status() == StatusCode::NOT_FOUND { return Ok(to_404()); } if confirm { let url = CONFIG.upstream_delete_url(&token, &file); client.delete(url).send().await?; render(HttpResponse::Ok(), |cursor| { self::templates::deleted(cursor, &file) }) } else { let details: Details = res.json().await?; render(HttpResponse::Ok(), move |cursor| { self::templates::confirm_delete( cursor, &Image { file, delete_token: token, details, }, ) }) } } fn to_404() -> HttpResponse { HttpResponse::TemporaryRedirect() .insert_header((LOCATION, "/404")) .finish() } #[tracing::instrument(name = "Not Found")] async fn not_found() -> Result { render(HttpResponse::NotFound(), |cursor| { self::templates::not_found(cursor) }) } async fn go_home() -> HttpResponse { HttpResponse::TemporaryRedirect() .insert_header((LOCATION, "/")) .finish() } #[tracing::instrument(name = "Render", skip(builder, f))] fn render( mut builder: HttpResponseBuilder, f: impl FnOnce(&mut Cursor<&mut Vec>) -> Result<(), std::io::Error>, ) -> Result { let min = { let mut bytes = vec![]; (f)(&mut Cursor::new(&mut bytes))?; minify_html::minify(&bytes, &minify_html::Cfg::spec_compliant()) }; Ok(builder .content_type(mime::TEXT_HTML.essence_str()) .body(min)) } fn init_tracing( service_name: &'static str, opentelemetry_url: Option<&Url>, ) -> Result<(), anyhow::Error> { opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new()); LogTracer::init()?; let targets: Targets = std::env::var("RUST_LOG") .unwrap_or_else(|_| "info".into()) .parse()?; let format_layer = tracing_subscriber::fmt::layer() .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE) .with_filter(targets.clone()); let console_layer = ConsoleLayer::builder() .with_default_env() .server_addr(([0, 0, 0, 0], 6669)) .event_buffer_capacity(1024 * 1024) .spawn(); let subscriber = Registry::default() .with(console_layer) .with(format_layer) .with(ErrorLayer::default()); 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_rt::main] async fn main() -> Result<(), anyhow::Error> { dotenv::dotenv().ok(); init_tracing("pict-rs-proxy", CONFIG.opentelemetry_url.as_ref())?; HttpServer::new(move || { let client = Client::builder() .wrap(Tracing) .add_default_header(("User-Agent", "pict-rs-frontend, v0.1.0")) .timeout(Duration::from_secs(30)) .finish(); App::new() .app_data(web::Data::new(client)) .wrap(TracingLogger::default()) .service(web::resource("/").route(web::get().to(index))) .service(web::resource("/upload").route(web::post().to(upload))) .service(web::resource("/image/{filename}").route(web::get().to(full_res))) .service(web::resource("/thumbnails").route(web::get().to(thumbs))) .service(web::resource("/view/{size}/{filename}").route(web::get().to(view))) .service(web::resource("/view/{filename}").route(web::get().to(view_original))) .service( web::resource("/thumb/{size}/{filetype}/{filename}") .route(web::get().to(thumbnail)), ) .service(web::resource("/static/{filename}").route(web::get().to(static_files))) .service(web::resource("/delete").route(web::get().to(delete))) .service(web::resource("/404").route(web::get().to(not_found))) .default_service(web::get().to(go_home)) }) .bind(CONFIG.addr)? .run() .await?; Ok(()) }