Rewrite pastebin.run in warp

This commit is contained in:
Konrad Borowski 2019-04-14 19:13:44 +02:00
parent e71ea2df5b
commit cfd05073e4
12 changed files with 641 additions and 1184 deletions

1177
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,16 +6,14 @@ edition = "2018"
license = "AGPL-3.0-or-later"
[dependencies]
actix-diesel = "0.3.0"
actix-web = "0.7.10"
ammonia = "2.0.0"
askama = { version = "0.8.0", features = ["with-actix-web"] }
askama = { version = "0.8.0" }
chrono = "0.4.6"
diesel = { version = "1.4.1", features = ["chrono", "postgres"] }
env_logger = "0.6.0"
futures = "0.1.25"
diesel = { version = "1.4.1", features = ["chrono", "postgres", "r2d2"] }
env_logger = { version = "0.6.0", default-features = false }
lazy_static = "1.3.0"
log = "0.4.6"
pulldown-cmark = "0.4.0"
rand = "0.6.5"
serde = { version = "1.0.88", features = ["derive"] }
warp = "0.1.15"

View File

@ -1,304 +1,22 @@
#[macro_use]
extern crate diesel;
mod models;
mod routes;
mod schema;
use actix_diesel::dsl::AsyncRunQueryDsl;
use actix_diesel::{AsyncError, Database};
use actix_web::error::InternalError;
use actix_web::fs::{NamedFile, StaticFiles};
use actix_web::http::header::{
CACHE_CONTROL, CONTENT_SECURITY_POLICY, LOCATION, REFERRER_POLICY, X_FRAME_OPTIONS,
X_XSS_PROTECTION,
};
use actix_web::http::{Method, StatusCode};
use actix_web::middleware::{DefaultHeaders, Logger};
use actix_web::{server, App, AsyncResponder, Form, HttpResponse, Path, State};
use ammonia::Builder;
use askama::actix_web::TemplateIntoResponse;
use askama::Template;
use chrono::{DateTime, Duration, Utc};
use diesel::prelude::*;
use futures::future;
use futures::prelude::*;
use lazy_static::lazy_static;
use log::info;
use pulldown_cmark::{html, Options, Parser};
use rand::prelude::*;
use schema::{languages, pastes};
use serde::de::IgnoredAny;
use serde::{Deserialize, Serialize};
use std::{env, io};
use diesel::r2d2::{ConnectionManager, PooledConnection};
use warp::Reply;
type AsyncResponse = Box<dyn Future<Item = HttpResponse, Error = actix_web::Error>>;
type Connection = PooledConnection<ConnectionManager<PgConnection>>;
#[derive(Template)]
#[template(path = "index.html")]
struct Index {
languages: Vec<Language>,
pub fn render(template: impl Template) -> impl Reply {
warp::reply::html(template.render().unwrap())
}
#[derive(Queryable)]
struct Language {
id: i32,
name: String,
}
fn index(db: State<Database<PgConnection>>) -> AsyncResponse {
fetch_languages(&db)
.and_then(|languages| Index { languages }.into_response())
.responder()
}
fn fetch_languages(
db: &Database<PgConnection>,
) -> impl Future<Item = Vec<Language>, Error = actix_web::Error> {
languages::table
.select((languages::language_id, languages::name))
.order((languages::priority.asc(), languages::name.asc()))
.load_async(&db)
.map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR).into())
}
#[derive(Deserialize)]
struct PasteForm {
language: i32,
code: String,
autodelete: Option<IgnoredAny>,
}
#[derive(Insertable)]
#[table_name = "pastes"]
struct NewPaste {
identifier: String,
delete_at: Option<DateTime<Utc>>,
language_id: i32,
paste: String,
}
const CHARACTERS: &[u8] = b"23456789bcdfghjkmnpqrstvwxzBCDFGHJKLMNPQRSTVWX_-";
fn insert_paste(db: State<Database<PgConnection>>, Form(form): Form<PasteForm>) -> AsyncResponse {
let mut rng = thread_rng();
let identifier: String = (0..10)
.map(|_| char::from(*CHARACTERS.choose(&mut rng).expect("a random character")))
.collect();
let delete_at = form.autodelete.map(|_| Utc::now() + Duration::hours(24));
let cloned_identifier = identifier.clone();
diesel::insert_into(pastes::table)
.values(NewPaste {
identifier,
delete_at,
language_id: form.language,
paste: form.code,
})
.execute_async(&db)
.map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR).into())
.map(move |_| {
HttpResponse::Found()
.header(LOCATION, format!("/{}", cloned_identifier))
.finish()
})
.responder()
}
#[derive(Template)]
#[template(path = "viewpaste.html")]
struct DisplayPaste {
languages: Vec<Language>,
paste: Paste,
}
#[derive(Queryable)]
struct QueryPaste {
paste: String,
language_id: i32,
delete_at: Option<DateTime<Utc>>,
is_markdown: bool,
no_follow: bool,
}
#[derive(Template)]
#[template(path = "404.html")]
struct PasteNotFound;
impl QueryPaste {
fn into_paste(self) -> Paste {
let QueryPaste {
paste,
language_id,
delete_at,
is_markdown,
no_follow,
} = self;
let markdown = if is_markdown {
render_markdown(&paste, no_follow)
} else {
String::new()
};
Paste {
paste,
language_id,
delete_at,
markdown,
}
}
}
struct Paste {
paste: String,
language_id: i32,
delete_at: Option<DateTime<Utc>>,
markdown: String,
}
fn display_paste(
db: State<Database<PgConnection>>,
requested_identifier: Path<String>,
) -> AsyncResponse {
delete_old_pastes(&db)
.and_then(|_| {
pastes::table
.inner_join(languages::table)
.select((
pastes::paste,
pastes::language_id,
pastes::delete_at,
languages::is_markdown,
pastes::no_follow,
))
.filter(pastes::identifier.eq(requested_identifier.into_inner()))
.get_optional_result_async::<QueryPaste>(&db)
.map(|paste| (db, paste))
})
.map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR).into())
.and_then(|(db, paste)| match paste {
None => future::Either::A(future::ok(
HttpResponse::NotFound().body(PasteNotFound.render().unwrap()),
)),
Some(paste) => future::Either::B(fetch_languages(&db).and_then(|languages| {
DisplayPaste {
languages,
paste: paste.into_paste(),
}
.into_response()
})),
})
.responder()
}
fn delete_old_pastes(
db: &Database<PgConnection>,
) -> impl Future<Item = (), Error = AsyncError<diesel::result::Error>> {
diesel::delete(pastes::table)
.filter(pastes::delete_at.lt(Utc::now()))
.execute_async(&db)
.map(|pastes| {
if pastes > 0 {
info!("Deleted {} paste(s)", pastes);
}
})
}
fn render_markdown(markdown: &str, no_follow: bool) -> String {
lazy_static! {
static ref FILTER: Builder<'static> = {
let mut builder = Builder::new();
builder.link_rel(Some("noopener noreferrer nofollow"));
builder
};
}
let mut output = String::new();
html::push_html(
&mut output,
Parser::new_ext(markdown, Options::ENABLE_TABLES),
);
if no_follow {
FILTER.clean(&output).to_string()
} else {
ammonia::clean(&output)
}
}
fn raw(db: State<Database<PgConnection>>, requested_identifier: Path<String>) -> AsyncResponse {
delete_old_pastes(&db)
.and_then(move |_| {
pastes::table
.select(pastes::paste)
.filter(pastes::identifier.eq(requested_identifier.into_inner()))
.get_optional_result_async::<String>(&db)
})
.map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR).into())
.map(|paste| match paste {
None => HttpResponse::NotFound().finish(),
Some(paste) => HttpResponse::Ok().content_type("text/plain").body(paste),
})
.responder()
}
fn favicon(_: ()) -> io::Result<NamedFile> {
NamedFile::open("static/favicon.ico")
}
#[derive(Serialize, Queryable)]
#[serde(rename_all = "camelCase")]
struct ApiLanguage {
mode: Option<String>,
mime: String,
}
fn api_language(db: State<Database<PgConnection>>, id: Path<i32>) -> AsyncResponse {
languages::table
.find(id.into_inner())
.select((languages::highlighter_mode, languages::mime))
.get_optional_result_async(&db)
.map_err(|e| InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR).into())
.map(|json: Option<ApiLanguage>| match json {
Some(json) => HttpResponse::Ok()
.header(CACHE_CONTROL, "max-age=14400")
.json(json),
None => HttpResponse::NotFound().finish(),
})
.responder()
}
fn main() -> io::Result<()> {
fn main() {
env_logger::init();
let db = Database::open(env::var("DATABASE_URL").expect("DATABASE_URL required"));
server::new(move || {
App::with_state(db.clone())
.middleware(Logger::default())
.middleware(
DefaultHeaders::new()
.header(
CONTENT_SECURITY_POLICY,
concat!(
"default-src 'self'; ",
"img-src *; ",
"object-src 'none'; ",
"base-uri 'none'; ",
"frame-ancestors 'none'",
),
)
.header(X_FRAME_OPTIONS, "DENY")
.header(X_XSS_PROTECTION, "1; mode=block")
.header(REFERRER_POLICY, "no-referrer"),
)
.resource("/", |r| {
r.method(Method::GET).with(index);
r.method(Method::POST).with(insert_paste);
})
.resource("/favicon.ico", |r| r.method(Method::GET).with(favicon))
.handler("/static", StaticFiles::new("static").unwrap())
.resource("/{identifier}", |r| {
r.method(Method::GET).with(display_paste)
})
.resource("/{identifier}/raw", |r| r.method(Method::GET).with(raw))
.resource("/api/v0/language/{id}", |r| {
r.method(Method::GET).with(api_language)
})
})
.bind("127.0.0.1:8080")?
.run();
Ok(())
warp::serve(routes::routes()).run(([127, 0, 0, 1], 8080));
}

5
src/models.rs Normal file
View File

@ -0,0 +1,5 @@
mod language;
mod paste;
pub use language::Language;
pub use paste::Paste;

19
src/models/language.rs Normal file
View File

@ -0,0 +1,19 @@
use crate::schema::languages::dsl::*;
use crate::PgConnection;
use diesel::prelude::*;
#[derive(Queryable)]
pub struct Language {
pub id: i32,
pub name: String,
}
impl Language {
pub fn fetch(db: &PgConnection) -> Vec<Language> {
languages
.select((language_id, name))
.order((priority.asc(), name.asc()))
.load(db)
.unwrap()
}
}

25
src/models/paste.rs Normal file
View File

@ -0,0 +1,25 @@
use crate::schema::pastes;
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use log::info;
#[derive(Queryable)]
pub struct Paste {
pub paste: String,
pub language_id: i32,
pub delete_at: Option<DateTime<Utc>>,
pub is_markdown: bool,
pub no_follow: bool,
}
impl Paste {
pub fn delete_old(db: &PgConnection) {
let pastes = diesel::delete(pastes::table)
.filter(pastes::delete_at.lt(Utc::now()))
.execute(db)
.unwrap();
if pastes > 0 {
info!("Deleted {} paste(s)", pastes);
}
}
}

92
src/routes.rs Normal file
View File

@ -0,0 +1,92 @@
mod api_language;
mod display_paste;
mod index;
mod insert_paste;
mod raw_paste;
use crate::render;
use askama::Template;
use diesel::r2d2::{ConnectionManager, Pool};
use std::env;
use warp::http::header::{
HeaderMap, HeaderValue, CONTENT_SECURITY_POLICY, REFERRER_POLICY, X_FRAME_OPTIONS,
X_XSS_PROTECTION,
};
use warp::http::StatusCode;
use warp::{path, Filter, Rejection, Reply};
#[derive(Template)]
#[template(path = "404.html")]
struct NotFound;
pub fn routes() -> impl Filter<Extract = (impl Reply,)> {
let pool = Pool::new(ConnectionManager::new(
env::var("DATABASE_URL").expect("DATABASE_URL required"),
))
.expect("Couldn't create a connection pool");
let db = warp::any().map(move || pool.get().unwrap());
let index = warp::path::end()
.and(warp::get2())
.and(db.clone())
.map(index::index);
let display_paste = warp::path::param()
.and(warp::path::end())
.and(warp::get2())
.and(db.clone())
.and_then(display_paste::display_paste);
let raw_paste = path!(String / "raw")
.and(warp::path::end())
.and(warp::get2())
.and(db.clone())
.and_then(raw_paste::raw_paste);
let insert_paste = warp::path::end()
.and(warp::post2())
.and(warp::body::content_length_limit(1_000_000))
.and(warp::body::form())
.and(db.clone())
.map(insert_paste::insert_paste);
let api_language = path!("api" / "v0" / "language" / i32)
.and(warp::path::end())
.and(warp::get2())
.and(db)
.and_then(api_language::api_language);
let static_dir = warp::path("static").and(warp::fs::dir("static"));
let favicon = warp::path("favicon.ico")
.and(warp::path::end())
.and(warp::fs::file("static/favicon.ico"));
let mut headers = HeaderMap::new();
headers.insert(
CONTENT_SECURITY_POLICY,
HeaderValue::from_static(concat!(
"default-src 'self'; ",
"img-src *; ",
"object-src 'none'; ",
"base-uri 'none'; ",
"frame-ancestors 'none'",
)),
);
headers.insert(X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
headers.insert(X_XSS_PROTECTION, HeaderValue::from_static("1; mode=block"));
headers.insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
index
.or(favicon)
.or(display_paste)
.or(raw_paste)
.or(insert_paste)
.or(api_language)
.or(static_dir)
.recover(not_found)
.with(warp::reply::with::headers(headers))
.with(warp::log("pastebinrun"))
}
fn not_found(rejection: Rejection) -> Result<impl Reply, Rejection> {
if rejection.is_not_found() {
Ok(warp::reply::with_status(
render(NotFound),
StatusCode::NOT_FOUND,
))
} else {
Err(rejection)
}
}

View File

@ -0,0 +1,26 @@
use crate::schema::languages::dsl::*;
use crate::Connection;
use diesel::prelude::*;
use serde::Serialize;
use warp::http::header::CACHE_CONTROL;
use warp::{Rejection, Reply};
#[derive(Serialize, Queryable)]
#[serde(rename_all = "camelCase")]
struct ApiLanguage {
mode: Option<String>,
mime: String,
}
pub fn api_language(id: i32, db: Connection) -> Result<impl Reply, Rejection> {
languages
.find(id)
.select((highlighter_mode, mime))
.get_result(&db)
.optional()
.unwrap()
.ok_or_else(warp::reject::not_found)
.map(|json: ApiLanguage| {
warp::reply::with_header(warp::reply::json(&json), CACHE_CONTROL, "max-age=14400")
})
}

View File

@ -0,0 +1,94 @@
use crate::models::{Language, Paste};
use crate::schema::{languages, pastes};
use crate::{render, Connection};
use ammonia::Builder;
use askama::Template;
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use lazy_static::lazy_static;
use pulldown_cmark::{Options, Parser};
use warp::{Rejection, Reply};
struct TemplatePaste {
paste: String,
language_id: i32,
delete_at: Option<DateTime<Utc>>,
markdown: String,
}
impl TemplatePaste {
fn from_paste(paste: Paste) -> Self {
let Paste {
paste,
language_id,
delete_at,
is_markdown,
no_follow,
} = paste;
let markdown = if is_markdown {
render_markdown(&paste, no_follow)
} else {
String::new()
};
Self {
paste,
language_id,
delete_at,
markdown,
}
}
}
fn render_markdown(markdown: &str, no_follow: bool) -> String {
lazy_static! {
static ref FILTER: Builder<'static> = {
let mut builder = Builder::new();
builder.link_rel(Some("noopener noreferrer nofollow"));
builder
};
}
let mut output = String::new();
pulldown_cmark::html::push_html(
&mut output,
Parser::new_ext(markdown, Options::ENABLE_TABLES),
);
if no_follow {
FILTER.clean(&output).to_string()
} else {
ammonia::clean(&output)
}
}
#[derive(Template)]
#[template(path = "viewpaste.html")]
struct DisplayPaste {
languages: Vec<Language>,
paste: TemplatePaste,
}
pub fn display_paste(
requested_identifier: String,
db: Connection,
) -> Result<impl Reply, Rejection> {
Paste::delete_old(&db);
let languages = Language::fetch(&db);
let paste = pastes::table
.inner_join(languages::table)
.select((
pastes::paste,
pastes::language_id,
pastes::delete_at,
languages::is_markdown,
pastes::no_follow,
))
.filter(pastes::identifier.eq(requested_identifier))
.get_result(&db)
.optional()
.unwrap();
paste.ok_or_else(warp::reject::not_found).map(|paste| {
render(DisplayPaste {
languages,
paste: TemplatePaste::from_paste(paste),
})
})
}

15
src/routes/index.rs Normal file
View File

@ -0,0 +1,15 @@
use crate::models::Language;
use crate::{render, Connection};
use askama::Template;
use warp::Reply;
#[derive(Template)]
#[template(path = "index.html")]
struct Index {
languages: Vec<Language>,
}
pub fn index(connection: Connection) -> impl Reply {
let languages = Language::fetch(&connection);
render(Index { languages })
}

View File

@ -0,0 +1,46 @@
use crate::schema::pastes;
use crate::Connection;
use chrono::{DateTime, Duration, Utc};
use diesel::prelude::*;
use rand::prelude::*;
use serde::de::IgnoredAny;
use serde::Deserialize;
use warp::http::Uri;
use warp::Reply;
const CHARACTERS: &[u8] = b"23456789bcdfghjkmnpqrstvwxzBCDFGHJKLMNPQRSTVWX_-";
#[derive(Deserialize)]
pub struct PasteForm {
language: i32,
code: String,
autodelete: Option<IgnoredAny>,
}
#[derive(Insertable)]
#[table_name = "pastes"]
struct NewPaste {
identifier: String,
delete_at: Option<DateTime<Utc>>,
language_id: i32,
paste: String,
}
pub fn insert_paste(form: PasteForm, db: Connection) -> impl Reply {
let mut rng = thread_rng();
let identifier: String = (0..10)
.map(|_| char::from(*CHARACTERS.choose(&mut rng).expect("a random character")))
.collect();
let delete_at = form.autodelete.map(|_| Utc::now() + Duration::hours(24));
let cloned_identifier = identifier.clone();
diesel::insert_into(pastes::table)
.values(NewPaste {
identifier,
delete_at,
language_id: form.language,
paste: form.code,
})
.execute(&db)
.unwrap();
warp::redirect(format!("/{}", cloned_identifier).parse::<Uri>().unwrap())
}

16
src/routes/raw_paste.rs Normal file
View File

@ -0,0 +1,16 @@
use crate::models::Paste;
use crate::schema::pastes::dsl::*;
use crate::Connection;
use diesel::prelude::*;
use warp::Rejection;
pub fn raw_paste(requested_identifier: String, db: Connection) -> Result<String, Rejection> {
Paste::delete_old(&db);
pastes
.select(paste)
.filter(identifier.eq(requested_identifier))
.get_result(&db)
.optional()
.unwrap()
.ok_or_else(warp::reject::not_found)
}