v0.3.2 // fencepost fixes

This commit is contained in:
minish 2026-05-11 00:22:50 -04:00
parent 4ce98de0e1
commit 2d429c75b7
Signed by: min
SSH Key Fingerprint: SHA256:mf+pUTmK92Y57BuCjlkBdd82LqztTfDCQIUp0fCKABc
7 changed files with 940 additions and 530 deletions

1326
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "breeze"
version = "0.3.1"
version = "0.3.2"
edition = "2024"
[profile.dev.package]
@ -9,22 +9,23 @@ tikv-jemalloc-sys = { opt-level = 3 }
[dependencies]
argh = "0.1.12"
atomic-time = "0.1.4"
axum = { version = "0.8.1", features = ["macros", "http2"] }
axum-extra = { version = "0.10.0", default-features = false, features = [
axum = { version = "0.8.9", features = ["macros", "http2"] }
axum-extra = { version = "0.12.6", default-features = false, features = [
"tracing",
"typed-header",
] }
base64 = "0.21"
base64 = "0.22"
bytes = "1"
color-eyre = "0.6"
dashmap = { version = "6.1.0", features = ["inline"] }
headers = "0.4"
heed = "0.22.1"
hmac = "0.12.1"
http = "1.2"
img-parts = "0.3"
rand = "0.9"
serde = { version = "1.0", features = ["derive"] }
serde_with = "3.12"
serde_with = "3.19"
sha2 = "0.10.9"
tokio = { version = "1", features = [
"rt-multi-thread",
@ -32,6 +33,7 @@ tokio = { version = "1", features = [
"net",
"fs",
"signal",
"test-util",
] }
tokio-stream = "0.1"
tokio-util = { version = "0.7", features = ["io"] }

View File

@ -210,9 +210,9 @@ impl Cache {
/// Returns if an upload is able to be cached
/// with the current caching rules
#[inline(always)]
#[inline]
pub fn will_use(&self, length: u64) -> bool {
length <= self.cfg.max_length
length <= (self.cfg.max_length as u64)
}
/// The background job that scans through the cache and removes inactive elements.
@ -221,6 +221,7 @@ impl Cache {
/// letting each entry keep track of expiry with its own task
pub async fn scanner(&self) {
let mut interval = time::interval(self.cfg.scan_freq);
interval.tick().await; // Skip first tick
loop {
// We put this first so that it doesn't scan the instant the server starts

View File

@ -20,7 +20,7 @@ fn default_motd() -> String {
pub struct EngineConfig {
/// The url that the instance of breeze is meant to be accessed from.
///
/// ex: https://picture.wtf would generate links like https://picture.wtf/p/abcdef.png
/// ex: `https://picture.wtf` would generate links like `https://picture.wtf/p/abcdef.png`
pub base_url: String,
/// Authentication key for new uploads, will be required if this is specified. (optional)
@ -69,8 +69,8 @@ pub struct DiskConfig {
#[derive(Deserialize, Clone)]
pub struct CacheConfig {
/// The maximum length in bytes that a file can be
/// before it skips cache (in seconds)
pub max_length: u64,
/// before it skips cache (in bytes)
pub max_length: usize,
/// The amount of time a file can last inside the cache (in seconds)
#[serde_as(as = "DurationSeconds")]

View File

@ -1,6 +1,6 @@
use std::{
io::SeekFrom,
ops::Bound,
ops::{Bound, RangeBounds},
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
@ -95,32 +95,53 @@ pub struct Engine {
/// Try to parse a `Range` header into an easier format to work with
fn resolve_range(range: Option<headers::Range>, full_len: u64) -> Option<(u64, u64)> {
let last_byte = full_len - 1;
// Prepare default range
let default = Some((0, full_len));
let (start, end) =
if let Some((start, end)) = range.and_then(|r| r.satisfiable_ranges(full_len).next()) {
// satisfiable_ranges will never return Excluded so this is ok
let start = if let Bound::Included(start_incl) = start {
start_incl
} else {
0
};
let end = if let Bound::Included(end_incl) = end {
end_incl
} else {
last_byte
};
// Take range, otherwise return
let Some(range) = range else {
return default; // unspecified; use default
};
(start, end)
} else {
(0, last_byte)
};
// Get iterator of satisfiable ranges
let mut ranges = range.satisfiable_ranges(full_len);
// catch ranges we can't satisfy
if end > last_byte || start > end {
// Take first range
let Some(range) = ranges.next() else {
return default; // empty; use default
};
// If there are multiple ranges, we will
// not process the request
if ranges.next().is_some() {
return None;
}
// Convert into a..b range
let start = match range.start_bound() {
Bound::Included(&x) => x,
Bound::Excluded(&x) => x.checked_add(1)?,
Bound::Unbounded => 0,
};
let end = match range.end_bound() {
Bound::Included(&x) => x.checked_add(1)?,
Bound::Excluded(&x) => x,
Bound::Unbounded => full_len,
};
// We can't handle bounds
// out of order
if start > end {
return None;
}
// We can't return more bytes
// than we have
if end > full_len {
return None;
}
// Return
Some((start, end))
}
@ -199,6 +220,7 @@ impl Engine {
return Ok(GetOutcome::NotFound);
};
// read length from disk
let full_len = self.disk.len(&f).await?;
// if possible, recache and send a cache response
@ -230,11 +252,11 @@ impl Engine {
return Ok(GetOutcome::RangeNotSatisfiable);
};
let range_len = (end - start) + 1;
// Set up file handle
f.seek(SeekFrom::Start(start)).await?;
let f = f.take(range_len);
let f = f.take(end - start);
// Return
let res = UploadResponse {
full_len,
range: (start, end),
@ -244,15 +266,27 @@ impl Engine {
}
};
// Resolve a..b range
let full_len = data.len() as u64;
let Some((start, end)) = resolve_range(range, full_len) else {
return Ok(GetOutcome::RangeNotSatisfiable);
};
// cut down to range
let data = data.slice((start as usize)..=(end as usize));
// Cut down to range
let data = {
// Convert types.
// These should never be greater than usize::MAX
// if I recall, because max cache length is a usize.
let (start, end): (usize, usize) = (
start.try_into().expect("start bound"),
end.try_into().expect("end bound"),
);
// build response
// Slice bytes
data.slice(start..end)
};
// Build response
let res = UploadResponse {
full_len,
range: (start, end),
@ -286,9 +320,10 @@ impl Engine {
// we found it in cache! take as many bytes as we can
let taking = full_data.len().min(SAMPLE_WANTED_BYTES);
let data = full_data.slice(0..taking);
// get len
let len = full_data.len() as u64;
// return
(data, len)
} else {
// not in cache, so try disk
@ -332,10 +367,10 @@ impl Engine {
if !self.has(&saved_name).await {
break saved_name;
} else {
// there was a name collision. loop and try again
info!("name collision! saved_name= {}", saved_name);
}
// there was a name collision. loop and try again
info!("name collision! saved_name= {}", saved_name);
}
}
@ -483,6 +518,7 @@ impl Engine {
};
}
// return w/ info for hash calculation
Ok((hash_sample.freeze(), observed_len))
}
@ -533,7 +569,7 @@ impl Engine {
Ok(m) => m,
// If anything fails, delete the upload and return the error
Err(err) => {
error!("failed processing upload!");
error!(?err, "failed processing upload!");
self.remove(&saved_name).await?;
return Err(err);

View File

@ -35,6 +35,17 @@ struct Args {
config: PathBuf,
}
/// Instantiates router.
fn router(engine: Engine) -> Router {
Router::new()
.route("/new", post(new::new))
.route("/p/{saved_name}", get(view::view))
.route("/del", get(delete::delete))
.route("/", get(index::index))
.route("/robots.txt", get(index::robots_txt))
.with_state(Arc::new(engine))
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
// Install color-eyre
@ -74,13 +85,7 @@ async fn main() -> eyre::Result<()> {
let engine = Engine::with_config(cfg.engine);
// Build main router
let app = Router::new()
.route("/new", post(new::new))
.route("/p/{saved_name}", get(view::view))
.route("/del", get(delete::delete))
.route("/", get(index::index))
.route("/robots.txt", get(index::robots_txt))
.with_state(Arc::new(engine));
let app = router(engine);
// Start web server
info!("starting server.");

View File

@ -45,7 +45,7 @@ impl IntoResponse for ViewError {
impl IntoResponse for UploadResponse {
fn into_response(self) -> Response {
let (start, end) = self.range;
let range_len = (end - start) + 1;
let range_len = end - start;
let mut res = match self.data {
UploadData::Cache(data) => data.into_response(),