Skip to content

Commit 812d427

Browse files
committed
Make expiration values fully configurable
Replace the awkward to use `WASTEBIN_MAX_PASTE_EXPIRATION` with `WASTEBIN_PASTE_EXPIRATIONS` which is a comma-separated list of seconds and an optional `=d` modifier to denote the default expiration. A simple but probably heuristic renders the seconds as a human readable representation on the index page.
1 parent 388a09a commit 812d427

File tree

8 files changed

+272
-50
lines changed

8 files changed

+272
-50
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
text.
2020
- **Breaking**: `POST`ing new entries via the JSON API now have to go via the
2121
`/api` root endpoint.
22+
- **Breaking**: Replace `WASTEBIN_MAX_PASTE_EXPIRATION` with a fully set of
23+
expirations via the `WASTEBIN_PASTE_EXPIRATIONS` variable.
2224
- Use the [two-face](https://docs.rs/two-face) crate for an extended syntax
2325
list.
2426
- Serve all CSS assets under hashed URL to avoid caching issues.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,8 @@ run-time behavior:
149149
| `WASTEBIN_DATABASE_PATH` | Path to the sqlite3 database file. | `:memory:` |
150150
| `WASTEBIN_HTTP_TIMEOUT` | Maximum number of seconds a request is processed until wastebin responds with 408. | `5` |
151151
| `WASTEBIN_MAX_BODY_SIZE` | Number of bytes to accept for POST requests. | `1048576`, i.e. 1 MB |
152-
| `WASTEBIN_MAX_PASTE_EXPIRATION` | Maximum allowed lifetime of a paste in seconds. Disable with 0. | `0` |
153152
| `WASTEBIN_PASSWORD_SALT` | Salt used to hash user passwords used for encrypting pastes. | `somesalt` |
153+
| `WASTEBIN_PASTE_EXPIRATIONS` | Possible paste expirations as a comma-separated list of seconds. Appending `=d` to one of the value makes it the default selection. | |
154154
| `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. |
155155
| `WASTEBIN_THEME` | Theme colors, one of `ayu`, `base16ocean`, `coldark`, `gruvbox`, `monokai`, `onehalf`, `solarized`. | `ayu` |
156156
| `WASTEBIN_TITLE` | HTML page title. | `wastebin` |

src/env.rs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::{db, highlight};
1+
use crate::{db, expiration, highlight};
22
use axum_extra::extract::cookie::Key;
33
use std::env::VarError;
44
use std::net::SocketAddr;
@@ -14,7 +14,7 @@ const VAR_CACHE_SIZE: &str = "WASTEBIN_CACHE_SIZE";
1414
const VAR_DATABASE_PATH: &str = "WASTEBIN_DATABASE_PATH";
1515
const VAR_HTTP_TIMEOUT: &str = "WASTEBIN_HTTP_TIMEOUT";
1616
const VAR_MAX_BODY_SIZE: &str = "WASTEBIN_MAX_BODY_SIZE";
17-
const VAR_MAX_PASTE_EXPIRATION: &str = "WASTEBIN_MAX_PASTE_EXPIRATION";
17+
const VAR_PASTE_EXPIRATIONS: &str = "WASTEBIN_PASTE_EXPIRATIONS";
1818
const VAR_SIGNING_KEY: &str = "WASTEBIN_SIGNING_KEY";
1919
const VAR_THEME: &str = "WASTEBIN_THEME";
2020
const VAR_PASSWORD_SALT: &str = "WASTEBIN_PASSWORD_SALT";
@@ -35,8 +35,8 @@ pub enum Error {
3535
SigningKey(String),
3636
#[error("failed to parse {VAR_HTTP_TIMEOUT}: {0}")]
3737
HttpTimeout(ParseIntError),
38-
#[error("failed to parse {VAR_MAX_PASTE_EXPIRATION}: {0}")]
39-
MaxPasteExpiration(ParseIntError),
38+
#[error("failed to parse {VAR_PASTE_EXPIRATIONS}: {0}")]
39+
ParsePasteExpiration(#[from] expiration::Error),
4040
#[error("unknown theme {0}")]
4141
UnknownTheme(String),
4242
}
@@ -139,9 +139,12 @@ pub fn http_timeout() -> Result<Duration, Error> {
139139
.map_err(Error::HttpTimeout)
140140
}
141141

142-
pub fn max_paste_expiration() -> Result<Option<u32>, Error> {
143-
std::env::var(VAR_MAX_PASTE_EXPIRATION)
144-
.ok()
145-
.map(|value| value.parse::<u32>().map_err(Error::MaxPasteExpiration))
146-
.transpose()
142+
/// Get expiration custom or default [`expiration::ExpirationSet`].
143+
pub fn expiration_set() -> Result<expiration::ExpirationSet, Error> {
144+
let set = std::env::var(VAR_PASTE_EXPIRATIONS).map_or_else(
145+
|_| "0,600,3600=d,86400,604800,2419200,29030400".parse::<expiration::ExpirationSet>(),
146+
|value| value.parse::<expiration::ExpirationSet>(),
147+
)?;
148+
149+
Ok(set)
147150
}

src/expiration.rs

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
use std::fmt::Display;
2+
use std::str::FromStr;
3+
use std::time::Duration;
4+
5+
#[derive(thiserror::Error, Debug)]
6+
pub enum Error {
7+
#[error("expiration value is empty")]
8+
Empty,
9+
#[error("failed to parse number: {0}")]
10+
ParsingNumber(std::num::ParseIntError),
11+
#[error("illegal modifier, only =d allowed")]
12+
IllegalModifier,
13+
#[error("multiple default values")]
14+
MultipleDefaults,
15+
}
16+
17+
/// Single expiration value that can be the default in a set of values.
18+
#[derive(Ord, Eq, PartialEq, PartialOrd)]
19+
pub struct Expiration {
20+
pub duration: Duration,
21+
pub default: bool,
22+
}
23+
24+
/// Multiple expiration values in ordered fashion.
25+
pub struct ExpirationSet(Vec<Expiration>);
26+
27+
/// A single [`Expiration`] can either be an unsigned number or an unsigned number followed by `=d`
28+
/// to denote a default expiration.
29+
impl FromStr for Expiration {
30+
type Err = Error;
31+
32+
fn from_str(s: &str) -> Result<Self, Self::Err> {
33+
let mut parts = s.split('=');
34+
35+
let Some(secs) = parts.next() else {
36+
return Err(Error::Empty);
37+
};
38+
39+
let secs = secs.parse::<u64>().map_err(Error::ParsingNumber)?;
40+
41+
let default = parts.next().map_or(Ok(false), |p| {
42+
if p == "d" {
43+
Ok(true)
44+
} else {
45+
Err(Error::IllegalModifier)
46+
}
47+
})?;
48+
49+
Ok(Self {
50+
duration: Duration::from_secs(secs),
51+
default,
52+
})
53+
}
54+
}
55+
56+
/// Print human-readable duration in a very rough approximation.
57+
impl Display for Expiration {
58+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59+
/// Computes `dividend` / `divisor` and returns `Some(fraction)` if > 0.
60+
fn div(dividend: u64, divisor: u64) -> Option<(u64, u64)> {
61+
let r = dividend / divisor;
62+
(r > 0).then_some((r, dividend % divisor))
63+
}
64+
65+
let mut secs = self.duration.as_secs();
66+
67+
if secs == 0 {
68+
return write!(f, "never");
69+
}
70+
71+
if let Some((years, rem)) = div(secs, 60 * 60 * 24 * 7 * 4 * 12) {
72+
if years > 1 {
73+
write!(f, "{years} years")?;
74+
} else {
75+
write!(f, "{years} year")?;
76+
}
77+
secs = rem;
78+
}
79+
80+
if let Some((months, rem)) = div(secs, 60 * 60 * 24 * 7 * 4) {
81+
if months > 1 {
82+
write!(f, "{months} months")?;
83+
} else {
84+
write!(f, "{months} month")?;
85+
}
86+
secs = rem;
87+
}
88+
89+
if let Some((weeks, rem)) = div(secs, 60 * 60 * 24 * 7) {
90+
if weeks > 1 {
91+
write!(f, "{weeks} weeks")?;
92+
} else {
93+
write!(f, "{weeks} week")?;
94+
}
95+
secs = rem;
96+
}
97+
98+
if let Some((days, rem)) = div(secs, 60 * 60 * 24) {
99+
if days > 1 {
100+
write!(f, "{days} days")?;
101+
} else {
102+
write!(f, "{days} day")?;
103+
}
104+
secs = rem;
105+
}
106+
107+
if let Some((hours, rem)) = div(secs, 60 * 60) {
108+
if hours > 1 {
109+
write!(f, "{hours} hours")?;
110+
} else {
111+
write!(f, "{hours} hour")?;
112+
}
113+
secs = rem;
114+
}
115+
116+
if let Some((minutes, rem)) = div(secs, 60) {
117+
if minutes > 1 {
118+
write!(f, "{minutes} minutes")?;
119+
} else {
120+
write!(f, "{minutes} minute")?;
121+
}
122+
secs = rem;
123+
}
124+
125+
if secs > 0 {
126+
write!(f, "{secs} seconds")?;
127+
}
128+
129+
Ok(())
130+
}
131+
}
132+
133+
impl FromStr for ExpirationSet {
134+
type Err = Error;
135+
136+
fn from_str(s: &str) -> Result<Self, Self::Err> {
137+
let mut values: Vec<Expiration> = s
138+
.split(',')
139+
.map(FromStr::from_str)
140+
.collect::<Result<_, _>>()?;
141+
142+
if values.iter().map(|exp| u64::from(exp.default)).sum::<u64>() > 1 {
143+
return Err(Error::MultipleDefaults);
144+
}
145+
146+
values.sort();
147+
148+
Ok(ExpirationSet(values))
149+
}
150+
}
151+
152+
impl ExpirationSet {
153+
/// Retrieve sorted vector of [`Expiration`] values.
154+
pub fn into_inner(self) -> Vec<Expiration> {
155+
self.0
156+
}
157+
}
158+
159+
#[cfg(test)]
160+
mod tests {
161+
use super::*;
162+
163+
impl Expiration {
164+
fn from_secs(secs: u64) -> Self {
165+
Self {
166+
duration: Duration::from_secs(secs),
167+
default: false,
168+
}
169+
}
170+
}
171+
172+
#[test]
173+
fn non_default_expiration() {
174+
let expiration = "60".parse::<Expiration>().unwrap();
175+
assert_eq!(expiration.duration, Duration::from_secs(60));
176+
assert!(!expiration.default);
177+
}
178+
179+
#[test]
180+
fn default_expiration() {
181+
let expiration = "60=d".parse::<Expiration>().unwrap();
182+
assert_eq!(expiration.duration, Duration::from_secs(60));
183+
assert!(expiration.default);
184+
}
185+
186+
#[test]
187+
fn expiration_set() {
188+
let expirations = "3600,60=d,48000"
189+
.parse::<ExpirationSet>()
190+
.unwrap()
191+
.into_inner();
192+
193+
assert_eq!(expirations.len(), 3);
194+
195+
assert_eq!(expirations[0].duration, Duration::from_secs(60));
196+
assert_eq!(expirations[1].duration, Duration::from_secs(3600));
197+
assert_eq!(expirations[2].duration, Duration::from_secs(48000));
198+
199+
assert!(expirations[0].default);
200+
assert!(!expirations[1].default);
201+
assert!(!expirations[2].default);
202+
}
203+
204+
#[test]
205+
fn multiple_defaults() {
206+
assert!("3600=d,60=d,48000".parse::<ExpirationSet>().is_err());
207+
}
208+
209+
#[test]
210+
fn formatting() {
211+
assert_eq!(format!("{}", Expiration::from_secs(30)), "30 seconds");
212+
assert_eq!(format!("{}", Expiration::from_secs(60)), "1 minute");
213+
assert_eq!(format!("{}", Expiration::from_secs(60 * 2)), "2 minutes");
214+
assert_eq!(format!("{}", Expiration::from_secs(60 * 60)), "1 hour");
215+
assert_eq!(format!("{}", Expiration::from_secs(60 * 60 * 2)), "2 hours");
216+
assert_eq!(format!("{}", Expiration::from_secs(60 * 60 * 24)), "1 day");
217+
assert_eq!(
218+
format!("{}", Expiration::from_secs(60 * 60 * 24 * 2)),
219+
"2 days"
220+
);
221+
assert_eq!(
222+
format!("{}", Expiration::from_secs(60 * 60 * 24 * 7)),
223+
"1 week"
224+
);
225+
assert_eq!(
226+
format!("{}", Expiration::from_secs(60 * 60 * 24 * 7 * 2)),
227+
"2 weeks"
228+
);
229+
assert_eq!(
230+
format!("{}", Expiration::from_secs(60 * 60 * 24 * 7 * 4)),
231+
"1 month"
232+
);
233+
assert_eq!(
234+
format!("{}", Expiration::from_secs(60 * 60 * 24 * 7 * 8)),
235+
"2 months"
236+
);
237+
assert_eq!(
238+
format!("{}", Expiration::from_secs(60 * 60 * 24 * 7 * 4 * 12)),
239+
"1 year"
240+
);
241+
assert_eq!(
242+
format!("{}", Expiration::from_secs(60 * 60 * 24 * 7 * 4 * 24)),
243+
"2 years"
244+
);
245+
}
246+
}

src/main.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ mod crypto;
2828
mod db;
2929
mod env;
3030
mod errors;
31+
mod expiration;
3132
mod handlers;
3233
mod highlight;
3334
mod id;
@@ -244,7 +245,7 @@ async fn start() -> Result<(), Box<dyn std::error::Error>> {
244245
let max_body_size = env::max_body_size()?;
245246
let base_url = env::base_url()?;
246247
let timeout = env::http_timeout()?;
247-
let max_expiration = env::max_paste_expiration()?;
248+
let expirations = env::expiration_set()?;
248249
let theme = env::theme()?;
249250
let title = env::title();
250251

@@ -255,9 +256,8 @@ async fn start() -> Result<(), Box<dyn std::error::Error>> {
255256
tracing::debug!("caching {cache_size} paste highlights");
256257
tracing::debug!("restricting maximum body size to {max_body_size} bytes");
257258
tracing::debug!("enforcing a http timeout of {timeout:#?}");
258-
tracing::debug!("maximum expiration time of {max_expiration:?} seconds");
259259

260-
let page = Arc::new(page::Page::new(title, base_url, theme, max_expiration));
260+
let page = Arc::new(page::Page::new(title, base_url, theme, expirations));
261261
let highlighter = Arc::new(highlight::Highlighter::default());
262262
let state = AppState {
263263
db,

src/page.rs

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::assets::{Asset, Css, Kind};
2+
use crate::expiration::{Expiration, ExpirationSet};
23
use crate::highlight::Theme;
34
use url::Url;
45

@@ -11,12 +12,6 @@ pub struct Assets {
1112
pub paste_js: Asset,
1213
}
1314

14-
pub struct Expiration {
15-
pub repr: &'static str,
16-
pub seconds: u32,
17-
pub selected: bool,
18-
}
19-
2015
pub struct Page {
2116
pub version: &'static str,
2217
pub title: String,
@@ -25,37 +20,12 @@ pub struct Page {
2520
pub expirations: Vec<Expiration>,
2621
}
2722

28-
impl Expiration {
29-
/// Create a new [`Expiration`] from the human-readable `repr` and given seconds.
30-
const fn new(repr: &'static str, seconds: u32) -> Self {
31-
Self {
32-
repr,
33-
seconds,
34-
selected: false,
35-
}
36-
}
37-
}
38-
3923
impl Page {
4024
/// Create new page meta data from generated `assets`, `title` and optional `base_url`.
4125
#[must_use]
42-
pub fn new(title: String, base_url: Url, theme: Theme, max_expiration: Option<u32>) -> Self {
43-
const OPTIONS: [Expiration; 7] = [
44-
Expiration::new("never", u32::MAX),
45-
Expiration::new("10 minutes", 600),
46-
Expiration::new("1 hour", 3600),
47-
Expiration::new("1 day", 86400),
48-
Expiration::new("1 week", 604_800),
49-
Expiration::new("1 month", 2_592_000),
50-
Expiration::new("1 year", 31_536_000),
51-
];
52-
26+
pub fn new(title: String, base_url: Url, theme: Theme, expirations: ExpirationSet) -> Self {
5327
let assets = Assets::new(theme);
54-
55-
let expirations = OPTIONS
56-
.into_iter()
57-
.filter(|expiration| max_expiration.is_none_or(|max| expiration.seconds <= max))
58-
.collect();
28+
let expirations = expirations.into_inner();
5929

6030
Self {
6131
version: env!("CARGO_PKG_VERSION"),

0 commit comments

Comments
 (0)