hyaenidae/profiles/src/store/comment.rs
asonix 22d8f85fba Profiles: Move bbcode, html sanitizing to Changes types
Handle Updated timestamp for profiles, servers, comments
2021-02-08 21:14:51 -06:00

558 lines
16 KiB
Rust

use super::{StoreError, Undo};
use crate::State;
use chrono::{DateTime, Utc};
use hyaenidae_content::{bbcode, html};
use sled::{Db, Transactional, Tree};
use std::io::Cursor;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct Comment {
id: Uuid,
submission_id: Uuid,
profile_id: Uuid,
comment_id: Option<Uuid>,
body: String,
body_source: Option<String>,
published: Option<DateTime<Utc>>,
updated: Option<DateTime<Utc>>,
deleted: bool,
}
#[derive(Debug)]
pub enum CommentChangesKind {
Create {
profile_id: Uuid,
submission_id: Uuid,
comment_id: Option<Uuid>,
},
Update {
id: Uuid,
},
}
#[derive(Debug)]
pub struct CommentChanges<'a> {
state: &'a State,
kind: CommentChangesKind,
body: Option<String>,
body_source: Option<String>,
published: Option<DateTime<Utc>>,
updated: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug)]
pub struct Store {
comment_tree: Tree,
submission_tree: Tree,
submission_created_tree: Tree,
reply_created_tree: Tree,
created_tree: Tree,
count_tree: Tree,
}
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
struct StoredComment {
id: Uuid,
submission_id: Uuid,
profile_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
comment_id: Option<Uuid>,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
body_source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
published: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
updated: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
deleted_at: Option<DateTime<Utc>>,
}
impl Comment {
pub fn id(&self) -> Uuid {
self.id
}
pub fn submission_id(&self) -> Uuid {
self.submission_id
}
pub fn profile_id(&self) -> Uuid {
self.profile_id
}
pub fn comment_id(&self) -> Option<Uuid> {
self.comment_id
}
pub fn body(&self) -> &str {
if !self.deleted() {
self.body.as_str()
} else {
"Comment Deleted"
}
}
pub fn body_source(&self) -> Option<&str> {
self.body_source.as_deref()
}
pub fn published(&self) -> Option<DateTime<Utc>> {
self.published
}
pub fn updated(&self) -> Option<DateTime<Utc>> {
self.updated
}
pub(crate) fn update<'a>(&self, state: &'a State) -> CommentChanges<'a> {
CommentChanges {
state,
kind: CommentChangesKind::Update { id: self.id() },
body: None,
body_source: None,
published: self.published,
updated: None,
}
}
pub fn deleted(&self) -> bool {
self.deleted
}
}
impl<'a> CommentChanges<'a> {
fn new(
state: &'a State,
profile_id: Uuid,
submission_id: Uuid,
comment_id: Option<Uuid>,
) -> Self {
CommentChanges {
state,
kind: CommentChangesKind::Create {
profile_id,
submission_id,
comment_id,
},
body: None,
body_source: None,
published: None,
updated: None,
}
}
pub(crate) fn body(&mut self, body: &str) -> &mut Self {
self.body = Some(html(body));
self
}
pub(crate) fn body_source(&mut self, body_source: &str) -> &mut Self {
self.body_source = Some(body_source.to_owned());
self.body(&bbcode(body_source, |v| v))
}
pub(crate) fn published(&mut self, published: DateTime<Utc>) -> &mut Self {
if self.published.is_none() {
self.published = Some(published);
}
self
}
pub(crate) fn updated(&mut self, updated: DateTime<Utc>) -> &mut Self {
if self.published.is_some() {
self.updated = Some(updated);
}
self
}
pub(crate) fn any_changes(&self) -> bool {
self.body.is_some() || self.published.is_some() || self.updated.is_some()
}
pub(crate) fn save(self) -> Result<Comment, StoreError> {
self.state.store.comments.save(&self)
}
}
impl Store {
pub(super) fn build(db: &Db) -> Result<Self, sled::Error> {
Ok(Store {
comment_tree: db.open_tree("profiles/comments")?,
submission_tree: db.open_tree("profiles/comments/submissions")?,
submission_created_tree: db.open_tree("profiles/comments/submissions/created")?,
reply_created_tree: db.open_tree("/profiles/comments/reply/created")?,
created_tree: db.open_tree("profiles/comments/created")?,
count_tree: db.open_tree("profiles/comments/count")?,
})
}
pub(crate) fn create<'a>(
&self,
state: &'a State,
submission_id: Uuid,
profile_id: Uuid,
comment_id: Option<Uuid>,
) -> CommentChanges<'a> {
CommentChanges::new(state, submission_id, profile_id, comment_id)
}
fn save(&self, changes: &CommentChanges) -> Result<Comment, StoreError> {
match &changes.kind {
CommentChangesKind::Create {
profile_id,
submission_id,
comment_id,
} => {
let id = self.do_create(*submission_id, *profile_id, *comment_id)?;
self.do_update(id, changes)
}
CommentChangesKind::Update { id } => self.do_update(*id, changes),
}
}
pub(crate) fn do_create(
&self,
submission_id: Uuid,
profile_id: Uuid,
comment_id: Option<Uuid>,
) -> Result<Uuid, StoreError> {
let mut id;
let mut stored_comment;
let now = Utc::now().into();
let mut stored_comment_vec = vec![];
while {
stored_comment_vec.clear();
let cursor = Cursor::new(&mut stored_comment_vec);
id = Uuid::new_v4();
stored_comment = StoredComment {
id,
submission_id,
profile_id,
comment_id,
body: String::new(),
body_source: None,
published: None,
updated: None,
created_at: now,
updated_at: now,
deleted_at: None,
};
serde_json::to_writer(cursor, &stored_comment)?;
self.comment_tree
.compare_and_swap(
id_comment_key(id),
None as Option<&[u8]>,
Some(stored_comment_vec.as_slice()),
)?
.is_err()
} {}
let res = [
&self.submission_tree,
&self.submission_created_tree,
&self.reply_created_tree,
&self.created_tree,
&self.count_tree,
]
.transaction(move |trees| {
let submission_tree = &trees[0];
let submission_created_tree = &trees[1];
let reply_created_tree = &trees[2];
let created_tree = &trees[3];
let count_tree = &trees[4];
submission_tree.insert(
submission_id_key(submission_id, id).as_bytes(),
id.as_bytes(),
)?;
created_tree.insert(created_comment_key(now, id).as_bytes(), id.as_bytes())?;
if let Some(comment_id) = comment_id {
reply_created_tree.insert(
reply_created_key(comment_id, now, id).as_bytes(),
id.as_bytes(),
)?;
} else {
submission_created_tree.insert(
submission_id_created_comment_key(submission_id, now, id).as_bytes(),
id.as_bytes(),
)?;
}
super::count(
count_tree,
&submission_id_comment_count_key(submission_id),
|c| c.saturating_add(1),
)?;
Ok(())
});
if let Err(e) = res {
self.comment_tree.remove(id_comment_key(id))?;
return Err(e.into());
}
Ok(id)
}
pub fn count(&self, submission_id: Uuid) -> Result<u64, StoreError> {
match self
.count_tree
.get(submission_id_comment_count_key(submission_id))?
{
Some(ivec) => Ok(String::from_utf8_lossy(&ivec).parse::<u64>().unwrap_or(0)),
None => Ok(0),
}
}
fn do_update(&self, id: Uuid, changes: &CommentChanges) -> Result<Comment, StoreError> {
let stored_comment_ivec = match self.comment_tree.get(id_comment_key(id))? {
Some(ivec) => ivec,
None => return Err(StoreError::Missing),
};
let mut stored_comment: StoredComment = serde_json::from_slice(&stored_comment_ivec)?;
if let Some(updated) = changes.updated {
if let Some(previously_updated) =
stored_comment.updated.or_else(|| stored_comment.published)
{
if updated < previously_updated {
return Err(StoreError::Outdated);
}
}
}
if let Some(body) = &changes.body {
stored_comment.body = body.clone();
}
if let Some(body_source) = &changes.body_source {
stored_comment.body_source = Some(body_source.clone());
}
if let Some(published) = changes.published {
stored_comment.published = Some(published);
}
if let Some(updated) = changes.updated {
stored_comment.updated = Some(updated);
}
stored_comment.updated_at = Utc::now().into();
let stored_comment_vec = serde_json::to_vec(&stored_comment)?;
if self
.comment_tree
.compare_and_swap(
id_comment_key(id),
Some(&stored_comment_ivec),
Some(stored_comment_vec.as_slice()),
)?
.is_err()
{
return Err(StoreError::DoubleStore);
}
Ok(stored_comment.into())
}
pub fn replies_for(&self, comment_id: Uuid) -> impl DoubleEndedIterator<Item = Uuid> {
self.reply_created_tree
.scan_prefix(reply_created_prefix(comment_id))
.values()
.filter_map(|res| res.ok())
.filter_map(uuid_from_ivec)
}
pub fn for_submission(&self, submission_id: Uuid) -> impl DoubleEndedIterator<Item = Uuid> {
self.submission_created_tree
.scan_prefix(submission_id_created_prefix(submission_id))
.values()
.filter_map(|res| res.ok())
.filter_map(uuid_from_ivec)
.rev()
}
fn date_range<K>(
&self,
range: impl std::ops::RangeBounds<K>,
) -> impl DoubleEndedIterator<Item = Uuid>
where
K: AsRef<[u8]>,
{
self.submission_created_tree
.range(range)
.values()
.filter_map(|res| res.ok())
.filter_map(uuid_from_ivec)
}
pub fn newer_than(&self, id: Uuid) -> impl DoubleEndedIterator<Item = Uuid> {
let this = self.clone();
self.comment_tree
.get(id_comment_key(id))
.ok()
.and_then(|opt| opt)
.and_then(|stored_comment_ivec| {
let stored_comment: StoredComment =
serde_json::from_slice(&stored_comment_ivec).ok()?;
Some((stored_comment.submission_id, stored_comment.created_at))
})
.into_iter()
.flat_map(move |(submission_id, created_at)| {
let range_start =
submission_id_created_comment_range_start(submission_id, created_at);
let range_start = range_start.as_bytes().to_vec();
this.date_range(range_start..)
})
}
pub fn older_than(&self, id: Uuid) -> impl DoubleEndedIterator<Item = Uuid> {
let this = self.clone();
self.comment_tree
.get(id_comment_key(id))
.ok()
.and_then(|opt| opt)
.and_then(|stored_comment_ivec| {
let stored_comment: StoredComment =
serde_json::from_slice(&stored_comment_ivec).ok()?;
Some((stored_comment.submission_id, stored_comment.created_at))
})
.into_iter()
.flat_map(move |(submission_id, created_at)| {
let range_end =
submission_id_created_comment_range_start(submission_id, created_at);
let range_end = range_end.as_bytes().to_vec();
this.date_range(..range_end)
})
.rev()
}
pub fn by_id(&self, id: Uuid) -> Result<Option<Comment>, StoreError> {
let stored_comment_ivec = match self.comment_tree.get(id_comment_key(id))? {
Some(ivec) => ivec,
None => return Ok(None),
};
let stored_comment: StoredComment = serde_json::from_slice(&stored_comment_ivec)?;
Ok(Some(stored_comment.into()))
}
pub(crate) fn delete(&self, comment_id: Uuid) -> Result<Option<Undo<Comment>>, StoreError> {
let stored_comment_ivec = match self.comment_tree.get(id_comment_key(comment_id))? {
Some(ivec) => ivec,
None => return Ok(None),
};
let mut stored_comment: StoredComment = serde_json::from_slice(&stored_comment_ivec)?;
stored_comment.deleted_at = Some(Utc::now());
stored_comment.body = String::new();
let stored_comment_vec = serde_json::to_vec(&stored_comment)?;
if self
.comment_tree
.compare_and_swap(
id_comment_key(comment_id),
Some(&stored_comment_ivec),
Some(stored_comment_vec),
)?
.is_err()
{
return Err(StoreError::DoubleStore);
}
Ok(Some(Undo(stored_comment.into())))
}
}
// Maps id -> Comment
fn id_comment_key(id: Uuid) -> String {
format!("/comment/{}/data", id)
}
// Maps submission_id -> id
fn submission_id_key(submission_id: Uuid, id: Uuid) -> String {
format!("/submission/{}/comment/{}", submission_id, id)
}
fn submission_id_created_prefix(submission_id: Uuid) -> String {
format!("/submission/{}/created", submission_id)
}
// Maps submission_id + created_at -> id
fn submission_id_created_comment_key(
submission_id: Uuid,
created_at: DateTime<Utc>,
id: Uuid,
) -> String {
format!(
"/submission/{}/created/{}/comment/{}",
submission_id,
created_at.to_rfc3339(),
id,
)
}
fn reply_created_key(reply_to_id: Uuid, created_at: DateTime<Utc>, id: Uuid) -> String {
format!(
"/reply-to/{}/created/{}/comment/{}",
reply_to_id,
created_at.to_rfc3339(),
id
)
}
fn reply_created_prefix(reply_to_id: Uuid) -> String {
format!("/reply-to/{}/created", reply_to_id)
}
fn submission_id_created_comment_range_start(
submission_id: Uuid,
created_at: DateTime<Utc>,
) -> String {
format!(
"/submission/{}/created/{}",
submission_id,
created_at.to_rfc3339()
)
}
// Maps created_at -> id
fn created_comment_key(created_at: DateTime<Utc>, id: Uuid) -> String {
format!("/created/{}/comment/{}", created_at.to_rfc3339(), id)
}
// Maps submission -> comment count
fn submission_id_comment_count_key(submission_id: Uuid) -> String {
format!("/submission/{}/count", submission_id)
}
fn uuid_from_ivec(ivec: sled::IVec) -> Option<Uuid> {
Uuid::from_slice(&ivec).ok()
}
impl From<StoredComment> for Comment {
fn from(sc: StoredComment) -> Self {
Comment {
id: sc.id,
submission_id: sc.submission_id,
profile_id: sc.profile_id,
comment_id: sc.comment_id,
body: sc.body,
body_source: sc.body_source,
published: sc.published,
updated: sc.updated,
deleted: sc.deleted_at.is_some(),
}
}
}