use super::markdown::render_and_sanitize;
use super::types::{MarkdownPage, WebsiteError, WebsiteMetadata, WebsiteResult};
use crate::crdt_manager::CrdtManager;
use std::sync::Arc;
use yrs::updates::decoder::Decode;
use yrs::{Doc, GetString, Map, ReadTxn, Text, Transact, WriteTxn};
pub struct WebsiteManager {
crdt_manager: Arc<CrdtManager>,
}
impl WebsiteManager {
pub fn new(crdt_manager: Arc<CrdtManager>) -> Self {
Self { crdt_manager }
}
fn validate_page_path(path: &str) -> WebsiteResult<String> {
if path.len() > 255 {
return Err(WebsiteError::InvalidPath("Path too long".into()));
}
if path.is_empty() {
return Err(WebsiteError::InvalidPath("Empty path".into()));
}
let parts: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
if parts.is_empty() {
return Err(WebsiteError::InvalidPath("No valid path components".into()));
}
let mut components = Vec::new();
for comp in parts {
if comp == "." || comp == ".." {
return Err(WebsiteError::InvalidPath("Path traversal attempt".into()));
}
if !comp
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(WebsiteError::InvalidPath(format!(
"Invalid characters in: {}",
comp
)));
}
if comp.chars().all(|c| c == '.') {
return Err(WebsiteError::InvalidPath("Invalid component".into()));
}
if comp.contains("..") {
return Err(WebsiteError::InvalidPath(
"Consecutive dots not allowed".into(),
));
}
components.push(comp);
}
Ok(components.join("/"))
}
fn page_doc_id(four_word_address: &str, path: &str) -> WebsiteResult<String> {
let safe_path = Self::validate_page_path(path)?;
Ok(format!("website:{}:page:{}", four_word_address, safe_path))
}
fn metadata_doc_id(four_word_address: &str) -> String {
format!("website:{}:metadata", four_word_address)
}
pub async fn save_page(
&self,
four_word_address: &str,
page: &MarkdownPage,
) -> WebsiteResult<()> {
let doc_id = Self::page_doc_id(four_word_address, &page.path)?;
let doc = Doc::new();
{
let mut txn = doc.transact_mut();
let root = txn.get_or_insert_map("root");
root.insert(&mut txn, "content", yrs::TextPrelim::new(&page.content));
CrdtManager::set_map_string(&root, &mut txn, "path", &page.path);
if let Some(ref title) = page.title {
CrdtManager::set_map_string(&root, &mut txn, "title", title);
}
CrdtManager::set_map_i64(&root, &mut txn, "created_at", page.created_at);
CrdtManager::set_map_i64(&root, &mut txn, "updated_at", page.updated_at);
}
self.crdt_manager
.save_document(&doc_id, "website", four_word_address, &doc)
.await?;
Ok(())
}
pub async fn load_page(
&self,
four_word_address: &str,
path: &str,
) -> WebsiteResult<MarkdownPage> {
let doc_id = Self::page_doc_id(four_word_address, path)?;
let doc = self
.crdt_manager
.load_document(&doc_id)
.await
.map_err(|_| WebsiteError::PageNotFound(path.to_string()))?;
let root = doc.get_or_insert_map("root");
let txn = doc.transact();
let content = if let Some(text_val) = root.get(&txn, "content") {
if let Ok(text_ref) = yrs::TextRef::try_from(text_val) {
text_ref.get_string(&txn)
} else {
String::new()
}
} else {
String::new()
};
let path =
CrdtManager::get_map_string(&root, &txn, "path").unwrap_or_else(|| path.to_string());
let title = CrdtManager::get_map_string(&root, &txn, "title");
let created_at = CrdtManager::get_map_i64(&root, &txn, "created_at").unwrap_or(0);
let updated_at = CrdtManager::get_map_i64(&root, &txn, "updated_at").unwrap_or(0);
Ok(MarkdownPage {
path,
content,
title,
created_at,
updated_at,
})
}
pub async fn load_page_doc(&self, four_word_address: &str, path: &str) -> WebsiteResult<Doc> {
let doc_id = Self::page_doc_id(four_word_address, path)?;
self.crdt_manager
.load_document(&doc_id)
.await
.map_err(|_| WebsiteError::PageNotFound(path.to_string()))
}
pub fn append_text(&self, doc: &Doc, text: &str) -> WebsiteResult<()> {
let root = doc.get_or_insert_map("root");
let mut txn = doc.transact_mut();
if let Some(content_val) = root.get(&txn, "content")
&& let Ok(content_text) = yrs::TextRef::try_from(content_val)
{
let len = content_text.len(&txn);
content_text.insert(&mut txn, len, text);
return Ok(());
}
Err(WebsiteError::Rendering("No content text found".to_string()))
}
pub fn insert_text_at(&self, doc: &Doc, index: u32, text: &str) -> WebsiteResult<()> {
let root = doc.get_or_insert_map("root");
let mut txn = doc.transact_mut();
if let Some(content_val) = root.get(&txn, "content")
&& let Ok(content_text) = yrs::TextRef::try_from(content_val)
{
content_text.insert(&mut txn, index, text);
return Ok(());
}
Err(WebsiteError::Rendering("No content text found".to_string()))
}
pub fn extract_content(&self, doc: &Doc) -> WebsiteResult<String> {
let root = doc.get_or_insert_map("root");
let txn = doc.transact();
if let Some(content_val) = root.get(&txn, "content")
&& let Ok(content_text) = yrs::TextRef::try_from(content_val)
{
return Ok(content_text.get_string(&txn));
}
Err(WebsiteError::Rendering("No content found".to_string()))
}
pub async fn merge_page_docs(&self, mut docs: Vec<Doc>) -> WebsiteResult<Doc> {
if docs.is_empty() {
return Err(WebsiteError::Rendering("No documents to merge".to_string()));
}
let base = docs.remove(0);
for doc in docs {
let update_bytes = doc
.transact()
.encode_state_as_update_v1(&yrs::StateVector::default());
let update = yrs::Update::decode_v1(&update_bytes)
.map_err(|e| WebsiteError::Rendering(format!("Failed to decode update: {}", e)))?;
let mut txn = base.transact_mut();
txn.apply_update(update);
}
Ok(base)
}
pub async fn list_pages(&self, four_word_address: &str) -> WebsiteResult<Vec<String>> {
let pages = self.crdt_manager.list_documents("website").await?;
let prefix = format!("website:{}:page:", four_word_address);
let mut result = Vec::new();
for doc_id in pages {
if doc_id.starts_with(&prefix) {
if let Some(path) = doc_id.strip_prefix(&prefix) {
result.push(path.to_string());
}
}
}
Ok(result)
}
pub async fn delete_page(&self, four_word_address: &str, path: &str) -> WebsiteResult<()> {
let doc_id = Self::page_doc_id(four_word_address, path)?;
self.crdt_manager.delete_document(&doc_id).await?;
Ok(())
}
pub async fn save_metadata(
&self,
four_word_address: &str,
metadata: &WebsiteMetadata,
) -> WebsiteResult<()> {
let doc_id = Self::metadata_doc_id(four_word_address);
let doc = Doc::new();
{
let mut txn = doc.transact_mut();
let root = txn.get_or_insert_map("root");
CrdtManager::set_map_string(
&root,
&mut txn,
"four_word_address",
&metadata.four_word_address,
);
CrdtManager::set_map_string(&root, &mut txn, "title", &metadata.title);
if let Some(ref desc) = metadata.description {
CrdtManager::set_map_string(&root, &mut txn, "description", desc);
}
CrdtManager::set_map_string(&root, &mut txn, "home_page", &metadata.home_page);
CrdtManager::set_map_bool(&root, &mut txn, "published", metadata.published);
if let Some(published_at) = metadata.published_at {
CrdtManager::set_map_i64(&root, &mut txn, "published_at", published_at);
}
CrdtManager::set_map_i64(&root, &mut txn, "created_at", metadata.created_at);
CrdtManager::set_map_i64(&root, &mut txn, "updated_at", metadata.updated_at);
}
self.crdt_manager
.save_document(&doc_id, "website", four_word_address, &doc)
.await?;
Ok(())
}
pub async fn get_metadata(&self, four_word_address: &str) -> WebsiteResult<WebsiteMetadata> {
let doc_id = Self::metadata_doc_id(four_word_address);
let doc = self
.crdt_manager
.load_document(&doc_id)
.await
.map_err(|_| WebsiteError::WebsiteNotFound(four_word_address.to_string()))?;
let root = doc.get_or_insert_map("root");
let txn = doc.transact();
let four_word_address = CrdtManager::get_map_string(&root, &txn, "four_word_address")
.unwrap_or_else(|| four_word_address.to_string());
let title = CrdtManager::get_map_string(&root, &txn, "title").unwrap_or_default();
let description = CrdtManager::get_map_string(&root, &txn, "description");
let home_page = CrdtManager::get_map_string(&root, &txn, "home_page")
.unwrap_or_else(|| "home.md".to_string());
let published = CrdtManager::get_map_bool(&root, &txn, "published").unwrap_or(false);
let published_at = CrdtManager::get_map_i64(&root, &txn, "published_at");
let created_at = CrdtManager::get_map_i64(&root, &txn, "created_at").unwrap_or(0);
let updated_at = CrdtManager::get_map_i64(&root, &txn, "updated_at").unwrap_or(0);
Ok(WebsiteMetadata {
four_word_address,
title,
description,
home_page,
published,
published_at,
created_at,
updated_at,
})
}
pub async fn publish(&self, four_word_address: &str, _publisher_id: &str) -> WebsiteResult<()> {
let mut metadata = self
.get_metadata(four_word_address)
.await
.unwrap_or_else(|_| WebsiteMetadata {
four_word_address: four_word_address.to_string(),
title: four_word_address.to_string(),
..Default::default()
});
metadata.published = true;
metadata.published_at = Some(chrono::Utc::now().timestamp());
metadata.updated_at = chrono::Utc::now().timestamp();
self.save_metadata(four_word_address, &metadata).await?;
Ok(())
}
pub async fn unpublish(&self, four_word_address: &str) -> WebsiteResult<()> {
let mut metadata = self.get_metadata(four_word_address).await?;
metadata.published = false;
metadata.updated_at = chrono::Utc::now().timestamp();
self.save_metadata(four_word_address, &metadata).await?;
Ok(())
}
pub async fn is_published(&self, four_word_address: &str) -> WebsiteResult<bool> {
match self.get_metadata(four_word_address).await {
Ok(metadata) => Ok(metadata.published),
Err(_) => Ok(false),
}
}
pub async fn resolve_address(
&self,
four_word_address: &str,
path: &str,
) -> WebsiteResult<MarkdownPage> {
self.load_page(four_word_address, path).await
}
pub async fn render_to_html(
&self,
four_word_address: &str,
path: &str,
) -> WebsiteResult<String> {
let page = self.load_page(four_word_address, path).await?;
Ok(render_and_sanitize(&page.content))
}
}