use actix_web::{ body::BodyStream, dev::HttpResponseBuilder, http::{ header::{CacheControl, CacheDirective, ContentType, LastModified, LOCATION}, StatusCode, }, middleware::Logger, web, App, HttpRequest, HttpResponse, HttpServer, ResponseError, }; use awc::Client; use once_cell::sync::Lazy; use std::{ io::Cursor, net::SocketAddr, time::{Duration, SystemTime}, }; use structopt::StructOpt; 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, } 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(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, thiserror::Error)] enum Error { #[error("{0}")] Io(#[from] std::io::Error), #[error("{0}")] SendRequest(#[from] awc::error::SendRequestError), #[error("{0}")] JsonPayload(#[from] awc::error::JsonPayloadError), #[error("Failed to minify html: {0}")] Minify(String), } 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.to_string()) }) { Ok(res) => res, Err(_) => HttpResponse::build(self.status_code()) .content_type(mime::TEXT_PLAIN.essence_str()) .body(self.to_string()), } } } async fn index() -> Result { render(HttpResponse::Ok(), |cursor| { self::templates::index(cursor, "/upload", "images[]") }) } 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.insert_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, } 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) }) } 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))) } 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) }) } 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)) }) } 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) } 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 } 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, } 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() } 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() } fn render( mut builder: HttpResponseBuilder, f: impl FnOnce(&mut Cursor<&mut Vec>) -> Result<(), std::io::Error>, ) -> Result { let mut bytes = vec![]; (f)(&mut Cursor::new(&mut bytes))?; minify_html::truncate( &mut bytes, &minify_html::Cfg { minify_js: false, minify_css: false, }, ) .map_err(|e| Error::Minify(format!("{:?}", e)))?; Ok(builder .content_type(mime::TEXT_HTML.essence_str()) .body(bytes)) } #[actix_rt::main] async fn main() -> Result<(), anyhow::Error> { dotenv::dotenv().ok(); if std::env::var("RUST_LOG").is_err() { std::env::set_var("RUST_LOG", "info"); } env_logger::init(); HttpServer::new(move || { let client = Client::builder() .header("User-Agent", "pict-rs-frontend, v0.1.0") .timeout(Duration::from_secs(30)) .finish(); App::new() .data(client) .wrap(Logger::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(()) }