pict-rs-proxy/src/main.rs

594 lines
15 KiB
Rust

use actix_web::{
body::BodyStream,
client::Client,
http::{
header::{CacheControl, CacheDirective, ContentType, LastModified, LOCATION},
StatusCode,
},
middleware::Logger,
web, App, HttpRequest, HttpResponse, HttpServer, ResponseError,
};
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<u64>, 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<Config> = 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<Vec<Image>>,
}
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<u64>) -> 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] actix_web::client::SendRequestError),
#[error("{0}")]
JsonPayload(#[from] awc::error::JsonPayloadError),
}
impl ResponseError for Error {
fn status_code(&self) -> StatusCode {
StatusCode::INTERNAL_SERVER_ERROR
}
fn error_response(&self) -> HttpResponse {
let mut builder = HttpResponse::build(self.status_code());
let mut cursor = Cursor::new(vec![]);
if let Err(e) = self::templates::error(&mut cursor, &self.to_string()) {
return builder
.content_type(mime::TEXT_PLAIN.essence_str())
.body(e.to_string());
}
builder
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner())
}
}
async fn index() -> Result<HttpResponse, Error> {
let mut cursor = Cursor::new(vec![]);
self::templates::index(&mut cursor, "/upload", "images[]")?;
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn upload(
req: HttpRequest,
body: web::Payload,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
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.header("X-Forwarded-For", addr.to_string())
} else {
client_request
};
let mut res = client_request.send_stream(body).await?;
let images = res.json::<Images>().await?;
let mut cursor = Cursor::new(vec![]);
self::templates::images(&mut cursor, images)?;
Ok(HttpResponse::build(res.status())
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
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<ThumbnailQuery>,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
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,
};
let mut cursor = Cursor::new(vec![]);
self::templates::thumbnails(&mut cursor, image, THUMBNAIL_SIZES)?;
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn image(
url: String,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
let client_request = client.request_from(url, req.head());
let client_request = if let Some(addr) = req.head().peer_addr {
client_request.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.header(name.clone(), value.clone());
}
Ok(client_res.body(BodyStream::new(res)))
}
async fn view_original(
file: web::Path<String>,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
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,
};
let mut cursor = Cursor::new(vec![]);
self::templates::view(&mut cursor, image, None)?;
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn view(
parts: web::Path<(u64, String)>,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
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,
};
let mut cursor = Cursor::new(vec![]);
self::templates::view(&mut cursor, image, Some(size))?;
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn thumbnail(
parts: web::Path<(u64, FileType, String)>,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
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<String>,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
let url = CONFIG.upstream_image_url(&filename.into_inner());
image(url, req, client).await
}
async fn static_files(filename: web::Path<String>) -> HttpResponse {
let filename = filename.into_inner();
if let Some(data) = self::templates::statics::StaticFile::get(&filename) {
return HttpResponse::Ok()
.set(LastModified(SystemTime::now().into()))
.set(CacheControl(vec![
CacheDirective::Public,
CacheDirective::MaxAge(365 * DAYS),
CacheDirective::Extension("immutable".to_owned(), None),
]))
.set(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<DeleteQuery>,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
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());
}
let mut cursor = Cursor::new(vec![]);
if confirm {
let url = CONFIG.upstream_delete_url(&token, &file);
client.delete(url).send().await?;
self::templates::deleted(&mut cursor, &file)?;
} else {
let details: Details = res.json().await?;
self::templates::confirm_delete(
&mut cursor,
&Image {
file,
delete_token: token,
details,
},
)?;
}
Ok(HttpResponse::Ok()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
fn to_404() -> HttpResponse {
HttpResponse::TemporaryRedirect()
.header(LOCATION, "/404")
.finish()
}
async fn not_found() -> Result<HttpResponse, Error> {
let mut cursor = Cursor::new(vec![]);
self::templates::not_found(&mut cursor)?;
Ok(HttpResponse::NotFound()
.content_type(mime::TEXT_HTML.essence_str())
.body(cursor.into_inner()))
}
async fn go_home() -> HttpResponse {
HttpResponse::TemporaryRedirect()
.header(LOCATION, "/")
.finish()
}
#[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(())
}