commit
ecaad53beb
@ -0,0 +1 @@ |
||||
/target |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@ |
||||
[package] |
||||
name = "postit" |
||||
version = "0.1.0" |
||||
authors = ["Ondřej Hruška <ondra@ondrovo.com>"] |
||||
edition = "2018" |
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html |
||||
|
||||
[dependencies] |
||||
clappconfig = "0.3.1" |
||||
rouille = "3.0.0" |
||||
parking_lot = "0.10.2" |
||||
serde_json = "1.0.52" |
||||
serde = "1.0.106" |
||||
serde_derive = "1.0" |
||||
log = "0.4.8" |
||||
siphasher = "0.3.3" |
||||
rand = "0.7.3" |
@ -0,0 +1,91 @@ |
||||
use clappconfig::{AppConfig, anyhow, clap::ArgMatches}; |
||||
use std::collections::HashMap; |
||||
use std::time::Duration; |
||||
|
||||
#[derive(Debug, Serialize, Deserialize)] |
||||
#[serde(default)] |
||||
#[serde(deny_unknown_fields)] |
||||
pub(crate) struct Config { |
||||
/// Log level
|
||||
pub(crate) logging: String, |
||||
|
||||
/// Per-module log levels
|
||||
pub(crate) log_levels: HashMap<String, String>, |
||||
|
||||
/// Server bind address
|
||||
pub(crate) host: String, |
||||
|
||||
/// Server port
|
||||
pub(crate) port: u16, |
||||
|
||||
/// Default expiry time in seconds
|
||||
#[serde(with = "serde_duration_secs")] |
||||
pub(crate) default_expiry: Duration, |
||||
|
||||
/// Max expiry time in seconds
|
||||
#[serde(with = "serde_duration_secs")] |
||||
pub(crate) max_expiry: Duration, |
||||
|
||||
/// Expired post clearing interval (triggered on write)
|
||||
#[serde(with = "serde_duration_secs")] |
||||
pub(crate) expired_gc_interval: Duration, |
||||
|
||||
/// Max uploaded file size in bytes
|
||||
pub(crate) max_file_size: usize, |
||||
} |
||||
|
||||
impl Default for Config { |
||||
fn default() -> Self { |
||||
Self { |
||||
logging: "debug".to_string(), |
||||
log_levels: Default::default(), |
||||
host: "0.0.0.0".to_string(), |
||||
port: 7745, |
||||
default_expiry: Duration::from_secs(60 * 10), |
||||
max_expiry: Duration::from_secs(60 * 10), |
||||
expired_gc_interval: Duration::from_secs(60), |
||||
max_file_size: 1 * (1024 * 1024) // 1MB
|
||||
} |
||||
} |
||||
} |
||||
|
||||
impl AppConfig for Config { |
||||
type Init = Config; |
||||
|
||||
fn logging(&self) -> &str { |
||||
&self.logging |
||||
} |
||||
|
||||
fn logging_mod_levels(&self) -> Option<&HashMap<String, String>> { |
||||
Some(&self.log_levels) |
||||
} |
||||
|
||||
fn configure(self, _clap: &ArgMatches) -> anyhow::Result<Self::Init> { |
||||
Ok(self) |
||||
} |
||||
} |
||||
|
||||
mod serde_duration_secs { |
||||
use serde::{self, Deserialize, Serializer, Deserializer}; |
||||
use std::time::Duration; |
||||
|
||||
pub fn serialize<S>( |
||||
value: &Duration, |
||||
se: S, |
||||
) -> Result<S::Ok, S::Error> |
||||
where |
||||
S: Serializer, |
||||
{ |
||||
se.serialize_u64(value.as_secs()) |
||||
} |
||||
|
||||
pub fn deserialize<'de, D>( |
||||
de: D, |
||||
) -> Result<Duration, D::Error> |
||||
where |
||||
D: Deserializer<'de>, |
||||
{ |
||||
let s: u64 = u64::deserialize(de)?; |
||||
Ok(Duration::from_secs(s)) |
||||
} |
||||
} |
@ -0,0 +1,400 @@ |
||||
#[macro_use] extern crate serde_derive; |
||||
#[macro_use] extern crate log; |
||||
|
||||
use std::time::{Instant, Duration}; |
||||
use parking_lot::Mutex; |
||||
use std::collections::HashMap; |
||||
use clappconfig::{AppConfig, anyhow}; |
||||
use std::io::Read; |
||||
use std::hash::{Hash, Hasher}; |
||||
use rand::rngs::OsRng; |
||||
use rand::Rng; |
||||
use rouille::{Request, Response, ResponseBody}; |
||||
use std::borrow::Cow; |
||||
use crate::config::Config; |
||||
|
||||
mod config; |
||||
|
||||
fn error_with_text(code : u16, text : impl Into<String>) -> Response { |
||||
Response { |
||||
status_code: code, |
||||
headers: vec![("Content-Type".into(), "text/plain; charset=utf8".into())], |
||||
data: rouille::ResponseBody::from_string(text), |
||||
upgrade: None, |
||||
} |
||||
} |
||||
|
||||
fn empty_error(code : u16) -> Response { |
||||
Response { |
||||
status_code: code, |
||||
headers: vec![("Content-Type".into(), "text/plain; charset=utf8".into())], |
||||
data: rouille::ResponseBody::empty(), |
||||
upgrade: None, |
||||
} |
||||
} |
||||
|
||||
type PostId = u64; |
||||
type Secret = u64; |
||||
type DataHash = u64; |
||||
|
||||
#[derive(Debug)] |
||||
struct Post { |
||||
mime : Cow<'static, str>, |
||||
hash : DataHash, |
||||
secret : Secret, |
||||
expires : Instant, |
||||
} |
||||
|
||||
impl Post { |
||||
pub fn is_expired(&self) -> bool { |
||||
self.expires < Instant::now() |
||||
} |
||||
} |
||||
|
||||
fn main() -> anyhow::Result<()> { |
||||
let config = Config::init("postit", "postit.json", None)?; |
||||
let serve_at = format!("{}:{}", config.host, config.port); |
||||
|
||||
let store = Mutex::new(Repository::new(config)); |
||||
|
||||
rouille::start_server(serve_at, move |req| { |
||||
let mut store_w = store.lock(); |
||||
let method = req.method(); |
||||
|
||||
info!("{} {}", method, req.raw_url()); |
||||
|
||||
store_w.gc_expired_posts_if_needed(); |
||||
|
||||
match method { |
||||
"POST" | "PUT" => { |
||||
store_w.serve_post_put(req) |
||||
} |
||||
"GET" | "HEAD" => { |
||||
store_w.serve_get_head(req) |
||||
} |
||||
"DELETE" => { |
||||
store_w.serve_delete(req) |
||||
} |
||||
_ => { |
||||
rouille::Response::empty_400() |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
|
||||
type PostsMap = HashMap<PostId, Post>; |
||||
type DataMap = HashMap<DataHash, (usize, Vec<u8>)>; |
||||
|
||||
struct Repository { |
||||
config: Config, |
||||
posts: PostsMap, |
||||
/// (use_count, data)
|
||||
data: DataMap, |
||||
/// Time of last expired posts GC
|
||||
last_gc_time: Instant, |
||||
} |
||||
|
||||
impl Repository { |
||||
fn new(config: Config) -> Self { |
||||
Repository { |
||||
config, |
||||
posts: Default::default(), |
||||
data: Default::default(), |
||||
last_gc_time: Instant::now(), |
||||
} |
||||
} |
||||
|
||||
fn serve_delete(&mut self, req : &Request) -> Response { |
||||
let post_id = match self.request_to_post_id(req, true) { |
||||
Ok(Some(pid)) => pid, |
||||
Ok(None) => return error_with_text(400, "Post ID required."), |
||||
Err(resp) => return resp |
||||
}; |
||||
|
||||
self.delete_post(post_id); |
||||
|
||||
Response::text("Deleted.") |
||||
} |
||||
|
||||
fn serve_post_put(&mut self, req : &Request) -> Response { |
||||
let post_id = match self.request_to_post_id(req, true) { |
||||
Ok(pid) => { |
||||
if req.method() == "PUT" && pid.is_none() { |
||||
return error_with_text(400, "PUT requires a file ID!"); |
||||
} else if req.method() == "POST" && pid.is_some() { |
||||
return error_with_text(400, "Use PUT to update a file!"); |
||||
} |
||||
|
||||
pid |
||||
}, |
||||
Err(resp) => return resp |
||||
}; |
||||
|
||||
debug!("Submit new data, post ID: {:?}", post_id); |
||||
|
||||
let mut data = vec![]; |
||||
if let Some(body) = req.data() { |
||||
// Read up to 1 byte past the limit to catch too large uploads.
|
||||
// We can't reply on the "Length" field, which is not present with chunked encoding.
|
||||
body.take(self.config.max_file_size as u64 + 1).read_to_end(&mut data).unwrap(); |
||||
if data.len() > self.config.max_file_size { |
||||
return empty_error(413); |
||||
} |
||||
} else { |
||||
return error_with_text(400, "Empty body!"); |
||||
} |
||||
|
||||
let mime = match req.header("Content-Type") { |
||||
None => None, |
||||
Some("application/x-www-form-urlencoded") => Some("text/plain"), |
||||
Some(v) => Some(v), |
||||
}; |
||||
|
||||
let expiry = match req.header("X-Expires") { |
||||
Some(text) => { |
||||
match text.parse() { |
||||
Ok(v) => { |
||||
let dur = Duration::from_secs(v); |
||||
if dur > self.config.max_expiry { |
||||
return error_with_text(400, |
||||
format!("Expiration time {} out of allowed range 0-{} s", |
||||
v, |
||||
self.config.max_expiry.as_secs() |
||||
)); |
||||
} |
||||
Some(dur) |
||||
}, |
||||
Err(_) => { |
||||
return error_with_text(400, "Malformed \"X-Expires\", use relative time in seconds."); |
||||
}, |
||||
} |
||||
} |
||||
None => None |
||||
}; |
||||
|
||||
if let Some(id) = post_id { |
||||
// UPDATE
|
||||
self.update(id, data, mime, expiry); |
||||
Response::text("Updated.") |
||||
} else { |
||||
// INSERT
|
||||
let (id, token) = self.insert(data, mime, expiry.unwrap_or(self.config.default_expiry)); |
||||
|
||||
Response::text(format!("{:016x}", id)) |
||||
.with_additional_header("X-Secret", format!("{:016x}", token)) |
||||
} |
||||
} |
||||
|
||||
fn serve_get_head(&mut self, req : &Request) -> Response { |
||||
let post_id = match self.request_to_post_id(req, false) { |
||||
Ok(Some(pid)) => pid, |
||||
Ok(None) => return error_with_text(400, "Post ID required."), |
||||
Err(resp) => return resp |
||||
}; |
||||
|
||||
if let Some(post) = self.posts.get(&post_id) { |
||||
if post.is_expired() { |
||||
warn!("GET of expired post!"); |
||||
Response::empty_404() |
||||
} else { |
||||
let data = self.data.get(&post.hash); |
||||
if data.is_none() { |
||||
error!("No matching data!"); |
||||
return error_with_text(500, "File data lost."); |
||||
} |
||||
|
||||
Response { |
||||
status_code: 200, |
||||
headers: vec![("Content-Type".into(), format!("{}; charset=utf8", post.mime).into())], |
||||
data: if req.method() == "HEAD" { |
||||
ResponseBody::empty() |
||||
} else { |
||||
ResponseBody::from_data(data.unwrap().1.clone()) |
||||
}, |
||||
upgrade: None, |
||||
} |
||||
} |
||||
} else { |
||||
warn!("No such post!"); |
||||
Response::empty_404() |
||||
} |
||||
} |
||||
|
||||
fn request_to_post_id(&self, req : &Request, check_secret : bool) -> Result<Option<PostId>, Response> { |
||||
let url = req.url(); |
||||
let stripped = url.trim_matches('/'); |
||||
|
||||
if stripped.is_empty() { |
||||
// No ID given
|
||||
return Ok(None); |
||||
} |
||||
|
||||
let id = match u64::from_str_radix(stripped, 16) { |
||||
Ok(bytes) => bytes, |
||||
Err(_) => { |
||||
return Err(error_with_text(400, "Bad file ID format!")); |
||||
}, |
||||
}; |
||||
|
||||
if check_secret { |
||||
// Check the write token
|
||||
match self.posts.get(&id) { |
||||
None/* | Some(_p) if _p.is_expired()*/ => { |
||||
return Err(error_with_text(404, "No file with this ID!")); |
||||
}, |
||||
Some(post) => { |
||||
if post.is_expired() { |
||||
warn!("Access of expired post!"); |
||||
return Err(error_with_text(404, "No file with this ID!")); |
||||
} |
||||
|
||||
let secret: u64 = match req.header("X-Secret").map(|v| u64::from_str_radix(v, 16)) { |
||||
Some(Ok(bytes)) => bytes, |
||||
None => { |
||||
return Err(error_with_text(400, "X-Secret required!")); |
||||
} |
||||
Some(Err(e)) => { |
||||
warn!("{:?}", e); |
||||
return Err(error_with_text(400, "Bad secret format!")); |
||||
}, |
||||
}; |
||||
|
||||
if post.secret != secret { |
||||
return Err(error_with_text(401, "Invalid secret!")); |
||||
} |
||||
}, |
||||
} |
||||
} |
||||
|
||||
// secret is now validated and we got an ID
|
||||
Ok(Some(id)) |
||||
} |
||||
|
||||
fn gc_expired_posts_if_needed(&mut self) { |
||||
if self.last_gc_time.elapsed() > self.config.expired_gc_interval { |
||||
self.gc_expired_posts(); |
||||
self.last_gc_time = Instant::now(); |
||||
} |
||||
} |
||||
|
||||
fn gc_expired_posts(&mut self) { |
||||
debug!("GC expired posts"); |
||||
|
||||
let mut to_rm = vec![]; |
||||
for post in &self.posts { |
||||
if post.1.is_expired() { |
||||
to_rm.push(*post.0); |
||||
} |
||||
} |
||||
|
||||
for id in to_rm { |
||||
debug!("Drop post ID {:016x}", id); |
||||
if let Some(post) = self.posts.remove(&id) { |
||||
Self::drop_data_or_decrement_rc(&mut self.data, post.hash); |
||||
} |
||||
} |
||||
} |
||||
|
||||
fn hash_data(data : &Vec<u8>) -> DataHash { |
||||
let mut hasher = siphasher::sip::SipHasher::new(); |
||||
data.hash(&mut hasher); |
||||
hasher.finish() |
||||
} |
||||
|
||||
fn store_data_or_increment_rc(data_map : &mut DataMap, hash : u64, data: Vec<u8>) { |
||||
match data_map.get_mut(&hash) { |
||||
None => { |
||||
debug!("Store new data hash #{:016x}", hash); |
||||
data_map.insert(hash, (1, data)); |
||||
}, |
||||
Some(entry) => { |
||||
debug!("Link new use of data hash #{:016x}", hash); |
||||
entry.0 += 1; // increment use counter
|
||||
}, |
||||
} |
||||
} |
||||
|
||||
fn drop_data_or_decrement_rc(data_map : &mut DataMap, hash : u64) { |
||||
if let Some(old_data) = data_map.get_mut(&hash) { |
||||
if old_data.0 > 1 { |
||||
old_data.0 -= 1; |
||||
debug!("Unlink use of data hash #{:016x} ({} remain)", hash, old_data.0); |
||||
} else { |
||||
debug!("Drop data hash #{:016x}", hash); |
||||
data_map.remove(&hash); |
||||
} |
||||
} |
||||
} |
||||
|
||||
fn insert(&mut self, data : Vec<u8>, mime : Option<&str>, expires : Duration) -> (PostId, Secret) { |
||||
info!("Insert post with data of len {} bytes, mime {}, expiry {:?}", |
||||
data.len(), mime.unwrap_or("unspecified"), |
||||
expires); |
||||
|
||||
let hash = Self::hash_data(&data); |
||||
|
||||
Self::store_data_or_increment_rc(&mut self.data, hash, data); |
||||
|
||||
let post_id = loop { |
||||
let id = OsRng.gen(); |
||||
if !self.posts.contains_key(&id) { |
||||
break id; |
||||
} |
||||
}; |
||||
|
||||
let secret = OsRng.gen(); |
||||
|
||||
debug!("Data hash = #{:016x}", hash); |
||||
debug!("Post ID = #{:016x}", post_id); |
||||
debug!("Secret = #{:016x}", secret); |
||||
|
||||
self.posts.insert(post_id, Post { |
||||
mime: mime.map(ToString::to_string).map(Cow::Owned) |
||||
.unwrap_or(Cow::Borrowed("application/octet-stream")), |
||||
hash, |
||||
secret, |
||||
expires: Instant::now() + expires |
||||
}); |
||||
|
||||
(post_id, secret) |
||||
} |
||||
|
||||
fn update(&mut self, id : PostId, data : Vec<u8>, mime : Option<&str>, expires : Option<Duration>) { |
||||
info!("Update post id #{:016x} with data of len {} bytes, mime {}, expiry {}", |
||||
id, data.len(), mime.unwrap_or("unchanged"), |
||||
expires.map(|v| Cow::Owned(format!("{:?}", v))) |
||||
.unwrap_or("unchanged".into())); |
||||
|
||||
let hash = Self::hash_data(&data); |
||||
let post = self.posts.get_mut(&id).unwrap(); // post existence was checked before
|
||||
|
||||
if hash != post.hash { |
||||
debug!("Data hash = #{:016x} (content changed)", hash); |
||||
|
||||
Self::drop_data_or_decrement_rc(&mut self.data, post.hash); |
||||
Self::store_data_or_increment_rc(&mut self.data, hash, data); |
||||
post.hash = hash; |
||||
} else { |
||||
debug!("Data hash = #{:016x} (no change)", hash); |
||||
} |
||||
|
||||
if let Some(mime) = mime { |
||||
if &post.mime != mime { |
||||
debug!("Content type changed to {}", mime); |
||||
post.mime = Cow::Owned(mime.to_string()); |
||||
} |
||||
} |
||||
|
||||
if let Some(exp) = expires { |
||||
debug!("Expiration changed to {:?} from now", exp); |
||||
post.expires = Instant::now() + exp; |
||||
} |
||||
} |
||||
|
||||
fn delete_post(&mut self, id : PostId) { |
||||
info!("Delete post id #{:016x}", id); |
||||
|
||||
let post = self.posts.remove(&id).unwrap(); // post existence was checked before
|
||||
Self::drop_data_or_decrement_rc(&mut self.data, post.hash); |
||||
} |
||||
} |
Loading…
Reference in new issue