Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ run-time behavior:
| `WASTEBIN_SIGNING_KEY` | Key to sign cookies. Must be at least 64 bytes long. | Random key generated at startup, i.e. cookies will become invalid after restarts and paste creators will not be able to delete their pastes. |
| `WASTEBIN_THEME` | Theme colors, one of `ayu`, `base16ocean`, `coldark`, `gruvbox`, `monokai`, `onehalf`, `solarized`. | `ayu` |
| `WASTEBIN_TITLE` | HTML page title. | `wastebin` |
| `WASTEBIN_RATELIMIT_INSERT` | Maximum allowed creation amount of new pastes per minute. | `0` (disabled) |
| `WASTEBIN_RATELIMIT_DELETE` | Maximum allowed delete attempts of existing pastes per minute. | `0` (disabled) |
| `RUST_LOG` | Log level. Besides the typical `trace`, `debug`, `info` etc. keys, you can also set the `tower_http` key to a log level to get additional request and response logs. | |


Expand Down
4 changes: 4 additions & 0 deletions crates/wastebin_core/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ pub mod vars {
pub const THEME: &str = "WASTEBIN_THEME";
/// Title.
pub const TITLE: &str = "WASTEBIN_TITLE";
/// Insert rate-limit.
pub const RATELIMIT_INSERT: &str = "WASTEBIN_RATELIMIT_INSERT";
/// Delete rate-limit.
pub const RATELIMIT_DELETE: &str = "WASTEBIN_RATELIMIT_DELETE";
}

pub(crate) fn password_hash_salt() -> String {
Expand Down
1 change: 1 addition & 0 deletions crates/wastebin_server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ hostname = "0.4.0"
http = "1.1.0"
mime = "0.3"
qrcodegen = "1"
ratelimit = "0.10"
sha2 = "0.10"
serde = { workspace = true }
syntect = { version = "5", default-features = false, features = ["html", "plist-load", "regex-fancy"] }
Expand Down
24 changes: 22 additions & 2 deletions crates/wastebin_server/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ use crate::{expiration, highlight};
use axum_extra::extract::cookie::Key;
use std::env::VarError;
use std::net::SocketAddr;
use std::num::{NonZeroUsize, ParseIntError};
use std::num::{NonZero, NonZeroU32, NonZeroUsize, ParseIntError};
use std::path::PathBuf;
use std::time::Duration;
use wastebin_core::db;
use wastebin_core::env::vars::{
self, ADDRESS_PORT, BASE_URL, CACHE_SIZE, DATABASE_PATH, HTTP_TIMEOUT, MAX_BODY_SIZE,
PASTE_EXPIRATIONS, SIGNING_KEY,
PASTE_EXPIRATIONS, RATELIMIT_DELETE, RATELIMIT_INSERT, SIGNING_KEY,
};

pub const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(5);
Expand All @@ -33,6 +33,10 @@ pub(crate) enum Error {
ParsePasteExpiration(#[from] expiration::Error),
#[error("unknown theme {0}")]
UnknownTheme(String),
#[error("failed to parse {RATELIMIT_INSERT}: {0}")]
RatelimitInsert(ParseIntError),
#[error("failed to parse {RATELIMIT_DELETE}: {0}")]
RatelimitDelete(ParseIntError),
}

pub fn title() -> String {
Expand Down Expand Up @@ -138,3 +142,19 @@ pub fn expiration_set() -> Result<expiration::ExpirationSet, Error> {

Ok(set)
}

pub fn ratelimit_insert() -> Result<Option<NonZeroU32>, Error> {
std::env::var(vars::RATELIMIT_INSERT)
.ok()
.map(|value| value.parse::<u32>().map_err(Error::RatelimitInsert))
.transpose()
.map(|op| op.and_then(NonZero::new))
}

pub fn ratelimit_delete() -> Result<Option<NonZeroU32>, Error> {
std::env::var(vars::RATELIMIT_DELETE)
.ok()
.map(|value| value.parse::<u32>().map_err(Error::RatelimitDelete))
.transpose()
.map(|op| op.and_then(NonZero::new))
}
10 changes: 6 additions & 4 deletions crates/wastebin_server/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub(crate) enum Error {
Id(#[from] id::Error),
#[error("malformed form data")]
MalformedForm,
#[error("rate-limit hit")]
RateLimit,
}

#[derive(Serialize)]
Expand All @@ -41,10 +43,10 @@ impl From<Error> for StatusCode {
match err {
Error::Database(db::Error::NoPassword) => StatusCode::BAD_REQUEST,
Error::Database(db::Error::NotFound) => StatusCode::NOT_FOUND,
Error::Database(db::Error::Delete)
| Error::Database(db::Error::Crypto(crypto::Error::ChaCha20Poly1305Decrypt)) => {
StatusCode::FORBIDDEN
}
Error::Database(
db::Error::Delete | db::Error::Crypto(crypto::Error::ChaCha20Poly1305Decrypt),
)
| Error::RateLimit => StatusCode::FORBIDDEN,
Error::Id(_) | Error::UrlParsing(_) => StatusCode::BAD_REQUEST,
Error::MalformedForm => StatusCode::UNPROCESSABLE_ENTITY,
Error::Join(_)
Expand Down
8 changes: 5 additions & 3 deletions crates/wastebin_server/src/handlers/delete/api.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
use crate::Database;
use crate::AppState;
use crate::errors::{Error, JsonErrorResponse};
use crate::handlers::extract::Uid;
use axum::extract::{Path, State};

use super::common_delete;

pub async fn delete(
Path(id): Path<String>,
State(db): State<Database>,
State(appstate): State<AppState>,
Uid(uid): Uid,
) -> Result<(), JsonErrorResponse> {
let id = id.parse().map_err(Error::Id)?;
db.delete_for(id, uid).await.map_err(Error::Database)?;
common_delete(&appstate, id, uid).await?;
Ok(())
}

Expand Down
8 changes: 5 additions & 3 deletions crates/wastebin_server/src/handlers/delete/form.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
use crate::handlers::extract::{Theme, Uid};
use crate::handlers::html::{ErrorResponse, make_error};
use crate::{Database, Page};
use crate::{AppState, Page};
use axum::extract::{Path, State};
use axum::response::Redirect;

use super::common_delete;

pub async fn delete(
Path(id): Path<String>,
State(db): State<Database>,
State(appstate): State<AppState>,
State(page): State<Page>,
Uid(uid): Uid,
theme: Option<Theme>,
) -> Result<Redirect, ErrorResponse> {
async {
let id = id.parse()?;
db.delete_for(id, uid).await?;
common_delete(&appstate, id, uid).await?;
Ok(Redirect::to("/"))
}
.await
Expand Down
28 changes: 28 additions & 0 deletions crates/wastebin_server/src/handlers/delete/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,30 @@
use std::sync::atomic::AtomicBool;

use wastebin_core::id::Id;

use crate::AppState;
use crate::Error;
use crate::Error::RateLimit;

pub mod api;
pub mod form;

async fn common_delete(appstate: &AppState, id: Id, uid: i64) -> Result<(), Error> {
if let Some(ref ratelimiter) = appstate.ratelimit_delete {
static RL_LOGGED: AtomicBool = AtomicBool::new(false);

if ratelimiter.try_wait().is_err() {
if !RL_LOGGED.fetch_or(true, std::sync::atomic::Ordering::Acquire) {
tracing::info!("Rate limiting paste deletions");
}

Err(RateLimit)?;
}

RL_LOGGED.store(false, std::sync::atomic::Ordering::Relaxed);
}

appstate.db.delete_for(id, uid).await?;

Ok(())
}
11 changes: 7 additions & 4 deletions crates/wastebin_server/src/handlers/insert/api.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
use crate::errors::{Error, JsonErrorResponse};
use crate::AppState;
use crate::errors::JsonErrorResponse;
use axum::Json;
use axum::extract::State;
use serde::{Deserialize, Serialize};
use std::num::NonZeroU32;
use wastebin_core::db::{Database, write};
use wastebin_core::db::write;
use wastebin_core::id::Id;

use super::common_insert;

#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct Entry {
pub text: String,
Expand Down Expand Up @@ -36,13 +39,13 @@ impl From<Entry> for write::Entry {
}

pub async fn post(
State(db): State<Database>,
State(appstate): State<AppState>,
Json(entry): Json<Entry>,
) -> Result<Json<RedirectResponse>, JsonErrorResponse> {
let id = Id::rand();
let entry: write::Entry = entry.into();
let path = format!("/{}", id.to_url_path(&entry));
db.insert(id, entry).await.map_err(Error::Database)?;
common_insert(&appstate, id, entry).await?;

Ok(Json::from(RedirectResponse { path }))
}
Expand Down
12 changes: 7 additions & 5 deletions crates/wastebin_server/src/handlers/insert/form.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
use crate::Page;
use crate::handlers::extract::{Theme, Uid};
use crate::handlers::html::make_error;
use crate::{AppState, Page};
use axum::extract::{Form, State};
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Redirect};
use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
use serde::{Deserialize, Serialize};
use std::num::NonZeroU32;
use wastebin_core::db::{Database, write};
use wastebin_core::db::write;
use wastebin_core::id::Id;

use super::common_insert;

#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct Entry {
pub text: String,
Expand Down Expand Up @@ -44,7 +46,7 @@ impl From<Entry> for write::Entry {

pub async fn post<E: std::fmt::Debug>(
State(page): State<Page>,
State(db): State<Database>,
State(appstate): State<AppState>,
jar: SignedCookieJar,
headers: HeaderMap,
uid: Option<Uid>,
Expand Down Expand Up @@ -73,7 +75,7 @@ pub async fn post<E: std::fmt::Debug>(
let uid = if let Some(Uid(uid)) = uid {
uid
} else {
db.next_uid().await?
appstate.db.next_uid().await?
};

let mut entry: write::Entry = entry.into();
Expand All @@ -86,7 +88,7 @@ pub async fn post<E: std::fmt::Debug>(
url = format!("burn/{url}");
}

db.insert(id, entry).await?;
common_insert(&appstate, id, entry).await?;
let url = format!("/{url}");

let cookie = Cookie::build(("uid", uid.to_string()))
Expand Down
28 changes: 28 additions & 0 deletions crates/wastebin_server/src/handlers/insert/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,30 @@
use std::sync::atomic::AtomicBool;

use wastebin_core::{db::write, id::Id};

use crate::AppState;
use crate::Error;
use crate::Error::RateLimit;

pub mod api;
pub mod form;

async fn common_insert(appstate: &AppState, id: Id, entry: write::Entry) -> Result<(), Error> {
if let Some(ref ratelimiter) = appstate.ratelimit_insert {
static RL_LOGGED: AtomicBool = AtomicBool::new(false);

if ratelimiter.try_wait().is_err() {
if !RL_LOGGED.fetch_or(true, std::sync::atomic::Ordering::Acquire) {
tracing::info!("Rate limiting paste insertions");
}

Err(RateLimit)?;
}

RL_LOGGED.store(false, std::sync::atomic::Ordering::Relaxed);
}

appstate.db.insert(id, entry).await?;

Ok(())
}
29 changes: 29 additions & 0 deletions crates/wastebin_server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use http::header::{
CONTENT_SECURITY_POLICY, REFERRER_POLICY, SERVER, X_CONTENT_TYPE_OPTIONS, X_FRAME_OPTIONS,
X_XSS_PROTECTION,
};
use ratelimit::Ratelimiter;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
Expand Down Expand Up @@ -46,6 +47,8 @@ pub(crate) struct AppState {
key: Key,
page: Page,
highlighter: Highlighter,
ratelimit_insert: Option<Arc<Ratelimiter>>,
ratelimit_delete: Option<Arc<Ratelimiter>>,
}

impl FromRef<AppState> for Key {
Expand Down Expand Up @@ -245,6 +248,8 @@ async fn start() -> Result<(), Box<dyn std::error::Error>> {
let expirations = env::expiration_set()?;
let theme = env::theme()?;
let title = env::title();
let ratelimit_insert = env::ratelimit_insert()?;
let ratelimit_delete = env::ratelimit_delete()?;

let cache = Cache::new(cache_size);
let db = Database::new(method)?;
Expand All @@ -253,15 +258,39 @@ async fn start() -> Result<(), Box<dyn std::error::Error>> {
tracing::debug!("caching {cache_size} paste highlights");
tracing::debug!("restricting maximum body size to {max_body_size} bytes");
tracing::debug!("enforcing a http timeout of {timeout:#?}");
tracing::debug!("ratelimiting insert amount to {ratelimit_insert:?} per minute");
tracing::debug!("ratelimiting delete attempts to {ratelimit_delete:?} per minute");

let page = Arc::new(page::Page::new(title, base_url, theme, expirations));
let highlighter = Arc::new(highlight::Highlighter::default());
let ratelimit_insert = ratelimit_insert.map(|rli| {
let value = rli.get().into();
Arc::new(
Ratelimiter::builder(value, Duration::from_secs(60))
.max_tokens(value)
.initial_available(value)
.build()
.expect("valid rate limiter values"),
)
});
let ratelimit_delete = ratelimit_delete.map(|rld| {
let value = rld.get().into();
Arc::new(
Ratelimiter::builder(value, Duration::from_secs(60))
.max_tokens(value)
.initial_available(value)
.build()
.expect("valid rate limiter values"),
)
});
let state = AppState {
db,
cache,
key,
page,
highlighter,
ratelimit_insert,
ratelimit_delete,
};

let listener = TcpListener::bind(&addr).await?;
Expand Down
Loading