|
|
|
@ -2,36 +2,38 @@ |
|
|
|
|
extern crate serde_derive; |
|
|
|
|
#[macro_use] |
|
|
|
|
extern crate log; |
|
|
|
|
#[macro_use] |
|
|
|
|
extern crate num_derive; |
|
|
|
|
|
|
|
|
|
use crate::config::Config; |
|
|
|
|
use crate::well_known_mime::Mime; |
|
|
|
|
use chrono::{DateTime, Utc}; |
|
|
|
|
use clappconfig::{anyhow, AppConfig}; |
|
|
|
|
use parking_lot::Mutex; |
|
|
|
|
use rand::rngs::OsRng; |
|
|
|
|
use rand::Rng; |
|
|
|
|
use rouille::{Request, Response, ResponseBody}; |
|
|
|
|
|
|
|
|
|
use std::borrow::Cow; |
|
|
|
|
use std::collections::HashMap; |
|
|
|
|
|
|
|
|
|
use std::fs::OpenOptions; |
|
|
|
|
use std::hash::{Hash, Hasher}; |
|
|
|
|
use std::io::Read; |
|
|
|
|
use std::time::Duration; |
|
|
|
|
use chrono::{Utc, DateTime}; |
|
|
|
|
use std::fs::OpenOptions; |
|
|
|
|
use std::fmt::Display; |
|
|
|
|
use serde::export::Formatter; |
|
|
|
|
use std::fmt; |
|
|
|
|
use serde::{Serialize, Serializer, Deserialize, Deserializer}; |
|
|
|
|
use serde::de::{DeserializeOwned, Visitor}; |
|
|
|
|
use crate::well_known_mime::Mime; |
|
|
|
|
use siphasher::sip::SipHasher; |
|
|
|
|
|
|
|
|
|
mod config; |
|
|
|
|
mod well_known_mime; |
|
|
|
|
|
|
|
|
|
const HDR_EXPIRES : &str = "X-Expires"; |
|
|
|
|
const HDR_SECRET : &str = "X-Secret"; |
|
|
|
|
/// Header to set expiry (seconds)
|
|
|
|
|
const HDR_EXPIRY: &str = "X-Expire"; |
|
|
|
|
/// Header to pass secret token for update/delete
|
|
|
|
|
const HDR_SECRET: &str = "X-Secret"; |
|
|
|
|
/// GET param to pass secret token (as a substitute for header)
|
|
|
|
|
const GET_EXPIRY: &str = "expire"; |
|
|
|
|
/// GET param to pass secret token (as a substitute for header)
|
|
|
|
|
const GET_SECRET: &str = "secret"; |
|
|
|
|
|
|
|
|
|
const FAVICON : &[u8] = include_bytes!("favicon.ico"); |
|
|
|
|
const FAVICON: &[u8] = include_bytes!("favicon.ico"); |
|
|
|
|
|
|
|
|
|
/// Post ID (represented as a 16-digit hex string)
|
|
|
|
|
type PostId = u64; |
|
|
|
@ -41,7 +43,7 @@ type Secret = u64; |
|
|
|
|
type DataHash = u64; |
|
|
|
|
|
|
|
|
|
/// Post stored in the repository
|
|
|
|
|
#[derive(Debug,Serialize,Deserialize)] |
|
|
|
|
#[derive(Debug, Serialize, Deserialize)] |
|
|
|
|
struct Post { |
|
|
|
|
/// Content-Type
|
|
|
|
|
mime: Mime, |
|
|
|
@ -59,6 +61,17 @@ impl Post { |
|
|
|
|
pub fn is_expired(&self) -> bool { |
|
|
|
|
self.expires < Utc::now() |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/// Get remaining lifetime
|
|
|
|
|
pub fn time_remains(&self) -> Duration { |
|
|
|
|
let seconds_remains = self.expires.signed_duration_since(Utc::now()) |
|
|
|
|
.num_seconds(); |
|
|
|
|
if seconds_remains < 0 { |
|
|
|
|
Duration::from_secs(0) |
|
|
|
|
} else { |
|
|
|
|
Duration::from_secs(seconds_remains as u64) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fn main() -> anyhow::Result<()> { |
|
|
|
@ -72,6 +85,7 @@ fn main() -> anyhow::Result<()> { |
|
|
|
|
error!("Load failed: {}", e); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
store.gc_expired_posts(); |
|
|
|
|
store |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
@ -82,7 +96,7 @@ fn main() -> anyhow::Result<()> { |
|
|
|
|
info!("{} {}", method, req.raw_url()); |
|
|
|
|
|
|
|
|
|
if req.url() == "/favicon.ico" { |
|
|
|
|
return Response::from_data("image/vnd.microsoft.icon", FAVICON); |
|
|
|
|
return decorate_response(Response::from_data("image/vnd.microsoft.icon", FAVICON)); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
store_w.gc_expired_posts_if_needed(); |
|
|
|
@ -104,14 +118,21 @@ fn main() -> anyhow::Result<()> { |
|
|
|
|
warn!("Error resp: {}", resp.status_code); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
resp |
|
|
|
|
decorate_response(resp) |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fn decorate_response(resp : Response) -> Response { |
|
|
|
|
resp.without_header("Server") |
|
|
|
|
.with_additional_header("Server", "postit.rs") |
|
|
|
|
.with_additional_header("Access-Control-Allow-Origin", "*") |
|
|
|
|
.with_additional_header("X-Version", env!("CARGO_PKG_VERSION")) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
type PostsMap = HashMap<PostId, Post>; |
|
|
|
|
type DataMap = HashMap<DataHash, (usize, Vec<u8>)>; |
|
|
|
|
|
|
|
|
|
#[derive(Debug,Serialize,Deserialize)] |
|
|
|
|
#[derive(Debug, Serialize, Deserialize)] |
|
|
|
|
struct Repository { |
|
|
|
|
#[serde(skip)] |
|
|
|
|
config: Config, |
|
|
|
@ -175,7 +196,7 @@ impl Repository { |
|
|
|
|
.read(true) |
|
|
|
|
.open(&self.config.persist_file)?; |
|
|
|
|
|
|
|
|
|
let result : Repository = if self.config.compression { |
|
|
|
|
let result: Repository = if self.config.compression { |
|
|
|
|
let flate = flate2::read::DeflateDecoder::new(file); |
|
|
|
|
bincode::deserialize_from(flate)? |
|
|
|
|
} else { |
|
|
|
@ -193,7 +214,7 @@ impl Repository { |
|
|
|
|
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."), |
|
|
|
|
Ok(None) => return error_with_text(400, "File ID required."), |
|
|
|
|
Err(resp) => return resp, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
@ -207,13 +228,16 @@ impl Repository { |
|
|
|
|
/// POST inserts a new record
|
|
|
|
|
/// PUT updates a record
|
|
|
|
|
fn serve_post_put(&mut self, req: &Request) -> Response { |
|
|
|
|
let is_post = req.method() == "POST"; |
|
|
|
|
let is_put = req.method() == "PUT"; |
|
|
|
|
|
|
|
|
|
// Post ID is empty for POST, set for PUT
|
|
|
|
|
let post_id = match self.request_to_post_id(req, true) { |
|
|
|
|
Ok(pid) => { |
|
|
|
|
if req.method() == "PUT" && pid.is_none() { |
|
|
|
|
if is_put && pid.is_none() { |
|
|
|
|
warn!("PUT without ID!"); |
|
|
|
|
return error_with_text(400, "PUT requires a file ID!"); |
|
|
|
|
} else if req.method() == "POST" && pid.is_some() { |
|
|
|
|
} else if is_post && pid.is_some() { |
|
|
|
|
warn!("POST with ID!"); |
|
|
|
|
return error_with_text(400, "Use PUT to update a file!"); |
|
|
|
|
} |
|
|
|
@ -232,11 +256,17 @@ impl Repository { |
|
|
|
|
body.take(self.config.max_file_size as u64 + 1) |
|
|
|
|
.read_to_end(&mut data) |
|
|
|
|
.unwrap(); |
|
|
|
|
if data.len() > self.config.max_file_size { |
|
|
|
|
|
|
|
|
|
if is_post && data.len() == 0 { |
|
|
|
|
warn!("Empty body!"); |
|
|
|
|
return error_with_text(400, "Empty body!"); |
|
|
|
|
} else if data.len() > self.config.max_file_size { |
|
|
|
|
warn!("Upload too large!"); |
|
|
|
|
return empty_error(413); |
|
|
|
|
} |
|
|
|
|
} else { |
|
|
|
|
return error_with_text(400, "Empty body!"); |
|
|
|
|
// Should not be possible
|
|
|
|
|
panic!("Req data None!"); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Convert "application/x-www-form-urlencoded" to text/plain (CURL uses this)
|
|
|
|
@ -247,7 +277,13 @@ impl Repository { |
|
|
|
|
Some(v) => Some(v), |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
let expiry = match req.header(HDR_EXPIRES) { |
|
|
|
|
let expiry = req.get_param(GET_EXPIRY); |
|
|
|
|
let mut expiry_s = expiry.as_ref().map(|s| s.as_str()); |
|
|
|
|
if expiry_s.is_none() { |
|
|
|
|
expiry_s = req.header(HDR_EXPIRY); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let expiry = match expiry_s { |
|
|
|
|
Some(text) => match text.parse() { |
|
|
|
|
Ok(v) => { |
|
|
|
|
let dur = Duration::from_secs(v); |
|
|
|
@ -266,31 +302,38 @@ impl Repository { |
|
|
|
|
Err(_) => { |
|
|
|
|
return error_with_text( |
|
|
|
|
400, |
|
|
|
|
"Malformed \"X-Expires\", use relative time in seconds.", |
|
|
|
|
"Malformed expiration, use relative time in seconds.", |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
None => None, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
if let Some(id) = post_id { |
|
|
|
|
let the_id; |
|
|
|
|
|
|
|
|
|
let resp = if let Some(id) = post_id { |
|
|
|
|
// UPDATE
|
|
|
|
|
self.update(id, data, mime, expiry); |
|
|
|
|
the_id = id; |
|
|
|
|
Response::text("Updated OK.") |
|
|
|
|
} else { |
|
|
|
|
// INSERT
|
|
|
|
|
let (id, token) = self.insert(data, mime, expiry.unwrap_or(self.config.default_expiry)); |
|
|
|
|
the_id = self.insert(data, mime, expiry.unwrap_or(self.config.default_expiry)); |
|
|
|
|
Response::text(format!("{:016x}", the_id)) |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
Response::text(format!("{:016x}", id)) |
|
|
|
|
.with_additional_header("X-Secret", format!("{:016x}", token)) |
|
|
|
|
} |
|
|
|
|
let post = self.posts.get(&the_id).unwrap(); |
|
|
|
|
|
|
|
|
|
resp |
|
|
|
|
.with_additional_header(HDR_SECRET, format!("{:016x}", post.secret)) |
|
|
|
|
.with_additional_header(HDR_EXPIRY, post.time_remains().as_secs().to_string()) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/// Serve a GET or HEAD request
|
|
|
|
|
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."), |
|
|
|
|
Ok(None) => return error_with_text(400, "File ID required."), |
|
|
|
|
Err(resp) => return resp, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
@ -305,12 +348,25 @@ impl Repository { |
|
|
|
|
return error_with_text(500, "File data lost."); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let seconds_remains = post.expires.signed_duration_since(Utc::now()) |
|
|
|
|
.num_seconds(); |
|
|
|
|
|
|
|
|
|
Response { |
|
|
|
|
status_code: 200, |
|
|
|
|
headers: vec![( |
|
|
|
|
"Content-Type".into(), |
|
|
|
|
format!("{}; charset=utf8", post.mime).into(), |
|
|
|
|
)], |
|
|
|
|
headers: vec![ |
|
|
|
|
( |
|
|
|
|
"Content-Type".into(), |
|
|
|
|
format!("{}; charset=utf8", post.mime).into(), |
|
|
|
|
), |
|
|
|
|
( |
|
|
|
|
"Cache-Control".into(), |
|
|
|
|
format!("public, max-age={}", seconds_remains).into() |
|
|
|
|
), |
|
|
|
|
( |
|
|
|
|
HDR_EXPIRY.into(), |
|
|
|
|
seconds_remains.to_string().into() |
|
|
|
|
) |
|
|
|
|
], |
|
|
|
|
data: if req.method() == "HEAD" { |
|
|
|
|
ResponseBody::empty() |
|
|
|
|
} else { |
|
|
|
@ -361,30 +417,38 @@ impl Repository { |
|
|
|
|
None => { |
|
|
|
|
warn!("ID {} does not exist!", id); |
|
|
|
|
return Err(error_with_text(404, "No file with this ID!")); |
|
|
|
|
}, |
|
|
|
|
} |
|
|
|
|
Some(post) => { |
|
|
|
|
if post.is_expired() { |
|
|
|
|
warn!("Access of expired file {}!", id); |
|
|
|
|
return Err(error_with_text(404, "No file with this ID!")); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let secret: u64 = match req.header(HDR_SECRET).map(|v| u64::from_str_radix(v, 16)) { |
|
|
|
|
Some(Ok(bytes)) => bytes, |
|
|
|
|
None => { |
|
|
|
|
warn!("Missing secret token!"); |
|
|
|
|
return Err(error_with_text(400, "Secret token required!")); |
|
|
|
|
} |
|
|
|
|
Some(Err(e)) => { |
|
|
|
|
warn!("Token parse error: {:?}", e); |
|
|
|
|
return Err(error_with_text(400, "Bad secret token format!")); |
|
|
|
|
}, |
|
|
|
|
}; |
|
|
|
|
let secret = req.get_param(GET_SECRET); |
|
|
|
|
let mut secret_str = secret.as_ref().map(|s| s.as_str()); |
|
|
|
|
|
|
|
|
|
if secret_str.is_none() { |
|
|
|
|
secret_str = req.header(HDR_SECRET); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
let secret: u64 = |
|
|
|
|
match secret_str.map(|v| u64::from_str_radix(v, 16)) { |
|
|
|
|
Some(Ok(bytes)) => bytes, |
|
|
|
|
None => { |
|
|
|
|
warn!("Missing secret token!"); |
|
|
|
|
return Err(error_with_text(400, "Secret token required!")); |
|
|
|
|
} |
|
|
|
|
Some(Err(e)) => { |
|
|
|
|
warn!("Token parse error: {:?}", e); |
|
|
|
|
return Err(error_with_text(400, "Bad secret token format!")); |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
if post.secret != secret { |
|
|
|
|
warn!("Secret token mismatch"); |
|
|
|
|
return Err(error_with_text(401, "Invalid secret token!")); |
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
@ -394,7 +458,12 @@ impl Repository { |
|
|
|
|
|
|
|
|
|
/// Drop expired posts, if cleaning is due
|
|
|
|
|
fn gc_expired_posts_if_needed(&mut self) { |
|
|
|
|
if Utc::now().signed_duration_since(self.last_gc_time).to_std().unwrap_or_default() > self.config.expired_gc_interval { |
|
|
|
|
if Utc::now() |
|
|
|
|
.signed_duration_since(self.last_gc_time) |
|
|
|
|
.to_std() |
|
|
|
|
.unwrap_or_default() |
|
|
|
|
> self.config.expired_gc_interval |
|
|
|
|
{ |
|
|
|
|
self.gc_expired_posts(); |
|
|
|
|
self.last_gc_time = Utc::now(); |
|
|
|
|
} |
|
|
|
@ -425,7 +494,7 @@ impl Repository { |
|
|
|
|
|
|
|
|
|
/// Get hash of a byte vector (for deduplication)
|
|
|
|
|
fn hash_data(data: &Vec<u8>) -> DataHash { |
|
|
|
|
let mut hasher = siphasher::sip::SipHasher::new(); |
|
|
|
|
let mut hasher = SipHasher::new(); |
|
|
|
|
data.hash(&mut hasher); |
|
|
|
|
hasher.finish() |
|
|
|
|
} |
|
|
|
@ -462,7 +531,7 @@ impl Repository { |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/// Insert a post
|
|
|
|
|
fn insert(&mut self, data: Vec<u8>, mime: Option<&str>, expires: Duration) -> (PostId, Secret) { |
|
|
|
|
fn insert(&mut self, data: Vec<u8>, mime: Option<&str>, expires: Duration) -> PostId { |
|
|
|
|
info!( |
|
|
|
|
"Insert post with data of len {} bytes, mime {}, expiry {:?}", |
|
|
|
|
data.len(), |
|
|
|
@ -473,12 +542,8 @@ impl Repository { |
|
|
|
|
let hash = Self::hash_data(&data); |
|
|
|
|
|
|
|
|
|
let mime = match mime { |
|
|
|
|
None => { |
|
|
|
|
Mime::from(tree_magic::from_u8(&data)) |
|
|
|
|
}, |
|
|
|
|
Some(explicit) => { |
|
|
|
|
Mime::from(explicit) |
|
|
|
|
}, |
|
|
|
|
None => Mime::from(tree_magic::from_u8(&data)), |
|
|
|
|
Some(explicit) => Mime::from(explicit), |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
Self::store_data_or_increment_rc(&mut self.data, hash, data); |
|
|
|
@ -492,7 +557,7 @@ impl Repository { |
|
|
|
|
|
|
|
|
|
let secret = OsRng.gen(); |
|
|
|
|
|
|
|
|
|
debug!("Post ID = #{:016x}", post_id); |
|
|
|
|
debug!("File ID = #{:016x} (http://{}:{}/{:016x})", post_id, self.config.host, self.config.port, post_id); |
|
|
|
|
debug!("Data hash = #{:016x}, mime {}", hash, mime); |
|
|
|
|
debug!("Secret = #{:016x}", secret); |
|
|
|
|
|
|
|
|
@ -508,7 +573,7 @@ impl Repository { |
|
|
|
|
|
|
|
|
|
self.dirty = true; |
|
|
|
|
|
|
|
|
|
(post_id, secret) |
|
|
|
|
post_id |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
/// Update a post by ID
|
|
|
|
@ -523,18 +588,21 @@ impl Repository { |
|
|
|
|
.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); |
|
|
|
|
if !data.is_empty() { |
|
|
|
|
let hash = Self::hash_data(&data); |
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
self.dirty = true; |
|
|
|
|
} else { |
|
|
|
|
debug!("Data hash = #{:016x} (no change)", hash); |
|
|
|
|
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; |
|
|
|
|
self.dirty = true; |
|
|
|
|
} else { |
|
|
|
|
debug!("Data hash = #{:016x} (no change)", hash); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if let Some(mime) = mime { |
|
|
|
@ -566,22 +634,25 @@ impl Repository { |
|
|
|
|
|
|
|
|
|
/// Serialize chrono unix timestamp as seconds
|
|
|
|
|
mod serde_chrono_datetime_as_unix { |
|
|
|
|
use chrono::{DateTime, NaiveDateTime, Utc}; |
|
|
|
|
use serde::{self, Deserialize, Deserializer, Serializer}; |
|
|
|
|
use chrono::{DateTime, Utc, NaiveDateTime}; |
|
|
|
|
|
|
|
|
|
pub fn serialize<S>(value: &DateTime<Utc>, se: S) -> Result<S::Ok, S::Error> |
|
|
|
|
where |
|
|
|
|
S: Serializer, |
|
|
|
|
where |
|
|
|
|
S: Serializer, |
|
|
|
|
{ |
|
|
|
|
se.serialize_i64(value.naive_utc().timestamp()) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
pub fn deserialize<'de, D>(de: D) -> Result<DateTime<Utc>, D::Error> |
|
|
|
|
where |
|
|
|
|
D: Deserializer<'de>, |
|
|
|
|
where |
|
|
|
|
D: Deserializer<'de>, |
|
|
|
|
{ |
|
|
|
|
let ts: i64 = i64::deserialize(de)?; |
|
|
|
|
Ok(DateTime::from_utc(NaiveDateTime::from_timestamp(ts, 0), Utc)) |
|
|
|
|
Ok(DateTime::from_utc( |
|
|
|
|
NaiveDateTime::from_timestamp(ts, 0), |
|
|
|
|
Utc, |
|
|
|
|
)) |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|