You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
yuurei/src/post.rs

247 lines
7.1 KiB

use super::*;
use cryptohelpers::sha256::Sha256Hash;
mod render;
pub use render::*;
use hard_format::formats::{
MaxLenString,
Base64FormattedString,
Base64FormattedStr,
self,
};
use mnemonic::Tripcode;
id_type!(PostID; "A unique post ID");
/// String type that limits its bytes to the ID string max limit.
pub type IDMaxString = MaxLenString<{defaults::POST_ID_MAX_LEN}>;
/// The timestamp type used in posts
pub type PostTimestamp = chrono::DateTime<defaults::Timezone>;
/// A size limited base64 formatting specifier
type PostBodyFormat = formats::BothFormat<formats::MaxLenFormat<{defaults::POST_BODY_MAX_SIZE}>, formats::Base64Format>;
/// A size limited base64 string
pub type PostBodyString = hard_format::FormattedString<PostBodyFormat>;
/// A size limited base64 string
pub type PostBodyStr = hard_format::FormattedStr<PostBodyFormat>;
/// Identifiers for a post
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
pub struct Ident
{
name: Option<IDMaxString>,
tripcode: Option<Tripcode>,
email: Option<IDMaxString>,
}
impl Ident
{
/// Create a new ident object
///
/// # Panics
/// If `name` or `email` are longer than `defaults::POST_ID_MAX_LEN`.
pub fn new(name: Option<&str>, tripcode: Option<Tripcode>, email: Option<&str>) -> Self
{
Self {
name: name.map(|x| IDMaxString::new(x.to_owned()).expect("Name too long")),
email: email.map(|x| IDMaxString::new(x.to_owned()).expect("Email too long")),
tripcode
}
}
/// The name of this user ident
pub fn name(&self) -> &str
{
self.name.as_ref().map(|x| x.as_str()).unwrap_or(defaults::ANON_NAME)
}
/// The tripcode of this user ident
pub fn tripcode(&self) -> Option<&Tripcode>
{
self.tripcode.as_ref()
}
/// The email of this user ident
pub fn email(&self) -> Option<&str>
{
self.email.as_ref().map(|x| x.as_str())
}
}
/// A single completed post.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Post
{
/// Unique ID for each post
id: PostID,
/// Identifiers for this post
ident: Ident,
/// The client-side generated AES key used for decrypting the body's text.
///
/// This AES key can be encrypted with any number of RSA public keys on the client side. There must be at least one, or the post body is lost and the post should be removed.
body_aes_keys: Vec<PostBodyString>,
/// The client-side encrypted body string
body: PostBodyString,
/// Signature of the body (optional).
signature: Option<Base64FormattedString>,
/// Hash of the body
hash: Sha256Hash,
/// When the post was created
created: PostTimestamp,
/// When the post was last edited.
///
/// # Notes
/// Each new edit is pushed to the end of the vec, creation does not count as an edit.
edited: Vec<PostTimestamp>,
/// Optional dynamic expiry duration.
expires_in: Option<tokio::time::Duration>,
}
// Time based functions
impl Post
{
/// The timestamp when the post should expire.
///
/// # Notes
/// If the conversion overflows, then the expiry time is treated as 0. (i.e. the post expires immediately).
pub fn expires_at(&self) -> PostTimestamp
{
self.created + chrono::Duration::from_std(self.expires_in.unwrap_or(defaults::POST_EXPIRE))
.unwrap_or(chrono::Duration::zero())
}
/// How long until the post has until it reaches its expiry time from its creation time.
///
/// If this value is *lower* than the value of `time_since_creation`, then the post has expired.
pub fn expires_in(&self) -> chrono::Duration
{
self.expires_at() - self.created
}
/// Time passed since this post was created as a Chrono `Duration`.
pub fn time_since_creation(&self) -> chrono::Duration
{
defaults::Timezone::now() - self.created
}
/// The timestamp for when this post was created
pub fn created(&self) -> PostTimestamp
{
self.created
}
/// A slice of timestamps showing when this post was edited, in order of those edits.
pub fn edited(&self) -> &[PostTimestamp]
{
&self.edited[..]
}
/// Has this post expired?
///
/// Expired posts should be removed
pub fn expired(&self) -> bool
{
if let Ok(dur) = &self.time_since_creation().to_std()
{
dur >= self.expires_in.as_ref().unwrap_or(&defaults::POST_EXPIRE)
} else {
// Conversion failed. Expire the post
true
}
}
}
// Ident based functions
impl Post
{
/// Get a mnemonic for this post's ID.
#[inline] pub fn post_id_mnemonic(&self) -> mnemonic::MnemonicHash
{
mnemonic::MnemonicHash::from_slice(self.id.id_as_bytes())
}
/// This post's unique identifier
#[inline] pub fn post_id(&self) -> &PostID
{
&self.id
}
/// The user-set name for this post if there is one.
#[inline] pub fn own_name(&self) -> Option<&str>
{
self.ident.name.as_ref().map(|x| x.as_str())
}
/// The name for this post.
///
/// If no name is set, returns the default anon name.
pub fn name(&self) -> &str
{
self.own_name().unwrap_or(defaults::ANON_NAME)
}
/// The email set for this post, if there is one.
pub fn email(&self) -> Option<&str>
{
self.ident.email.as_ref().map(|x| x.as_str())
}
/// Get the tripcode of this post, if there is one.
pub fn tripcode(&self) -> Option<&Tripcode>
{
self.ident.tripcode.as_ref()
}
/// The AES encrypted body of this post
pub fn body(&self) -> &PostBodyStr
{
self.body.as_ref()
}
/// An iterator of RSA ciphertexts of the AES key used to encrypt the body text.
pub fn body_keys<'a>(&'a self) -> impl Iterator<Item = &'a PostBodyStr> + ExactSizeIterator + 'a
{
self.body_aes_keys.iter().map(|x| x.as_ref())
}
/// The PEM formatted signature of this post, if there is one.
pub fn signature(&self) -> Option<&Base64FormattedStr>
{
self.signature.as_ref().map(|x| x.as_ref())
}
}
#[cfg(test)]
mod tests
{
#[test]
fn post_serialise()
{
use std::convert::TryInto;
let post = super::Post {
id: super::PostID::id_new(),
ident: super::Ident {
name: Some("Some name".to_owned().try_into().unwrap()),
email: None,
tripcode: Some(super::Tripcode::generate("uhh hello").unwrap()),
},
body: super::PostBodyString::new(crate::hard_format::formats::Base64FormattedString::encode("Hello world").into()).unwrap(),
body_aes_keys: vec![super::PostBodyString::new(crate::hard_format::formats::Base64FormattedString::encode("TODO").into()).unwrap()],
signature: None,
hash: Default::default(),
created: crate::defaults::Timezone::now(),
edited: Default::default(),
expires_in: None,
};
eprintln!("Post as html: {}", html! { body { (post) } }.into_string());
println!("Post is: {:?}", post);
let post_json = serde_json::to_vec(&post).expect("Serialise");
println!("Post json: {}", std::str::from_utf8(&post_json[..]).unwrap());
let post2: super::Post = serde_json::from_slice(&post_json[..]).expect("Deserialise");
assert_eq!(post, post2);
println!("Post was: {:?}", post2);
}
}