use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, Weak};
use anyhow::{Result, bail};
use axum::Router;
use devpage_core::check::Preview;
use devpage_core::comments::{Comments, Identity};
use devpage_core::designs;
use devpage_core::document::{Document, Kind};
use devpage_core::folder::{Folder, ServedFolder};
use devpage_design::Theme;
use sha2::{Digest, Sha256};
use super::Config;
use super::cache::{Cache, Served};
use super::metrics::{self, Stage};
use super::searches::{self, Searches};
use crate::adapters::bucket::Bucket;
use crate::adapters::bundle;
use crate::adapters::changes::{Change, Changes};
use crate::adapters::sqlite::{
Deleted, DocumentRow, DocumentThreads, Publish, Sqlite, ThreadRow, VersionRow,
};
use crate::drive::api_types::Refusal;
use crate::drive::http::{self, App, Commenting};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Mount {
Latest,
Pinned(u64),
}
impl Mount {
pub fn path(self, id: &str) -> String {
match self {
Mount::Latest => format!("/d/{id}"),
Mount::Pinned(n) => format!("/d/{id}/v/{n}"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Deletion {
Document,
Version(u64),
}
pub struct Version {
pub id: String,
pub number: u64,
pub mount: Mount,
served: Served,
}
impl Version {
pub fn router(&self) -> Router {
self.served.router.clone()
}
}
pub struct Download {
pub bytes: Vec<u8>,
pub media: &'static str,
pub name: String,
}
#[derive(Clone)]
pub struct Published(Arc<Parts>);
struct Parts {
store: Arc<Sqlite>,
bucket: Bucket,
cache: Arc<Cache>,
scratch: PathBuf,
identity: Identity,
designs: designs::Store,
locks: Locks,
buses: Mutex<HashMap<String, Changes>>,
searches: Arc<Searches>,
previews: Mutex<HashMap<(String, u64), Weak<Preview>>>,
}
impl Published {
pub fn open(config: &Config) -> Result<Published> {
let bucket = Bucket::open(&config.bucket_url)?;
let scratch = config.data_dir.join("scratch");
let _ = std::fs::remove_dir_all(&scratch);
std::fs::create_dir_all(&scratch)?;
Ok(Published(Arc::new(Parts {
store: Arc::new(
Sqlite::open(&config.data_dir.join("store.db"))?
.timed(|took| metrics::observe(Stage::Store, took)),
),
bucket,
cache: Arc::new(Cache::open(
&config.data_dir.join("cache"),
config.cache_max_bytes,
config.cache_policy.eviction(),
config.memory_cache_versions,
)?),
scratch: scratch.canonicalize()?,
identity: match &config.user_header {
Some(header) => Identity::Header(header.clone()),
None => Identity::Local("anonymous".into()),
},
designs: designs::Store::new(),
locks: Locks::default(),
buses: Mutex::default(),
searches: Arc::new(Searches::new(searches::BUDGET)),
previews: Mutex::default(),
})))
}
pub fn in_memory(&self) -> (usize, usize) {
self.0.cache.in_memory()
}
pub fn cache_bytes(&self) -> u64 {
self.0.cache.size()
}
pub fn identity(&self) -> &Identity {
&self.0.identity
}
pub async fn publish(
&self,
to: Option<&str>,
entry: &str,
author: &str,
zip: Vec<u8>,
) -> Result<(String, u64), Refusal> {
let parts = &self.0;
let id = match to {
Some(id) => {
parts.store.latest(id)?.ok_or(Refusal::NotFound)?;
id.to_string()
}
None => uuid::Uuid::new_v4().hyphenated().to_string(),
};
let id = id.as_str();
let scratch = Scratch::new(&parts.scratch)?;
let (kind, title) = metrics::timed(
Stage::PublishCheck,
check(&parts.designs, &zip, entry, &scratch.0),
)
.await
.map_err(|e| Refusal::Unprocessable(format!("{e:#}")))?;
let sha256 = format!("{:x}", Sha256::digest(&zip));
let size = zip.len() as u64;
let _one_at_a_time = parts.locks.hold(id).await;
let version = parts.store.next_number(id)?;
let folder = ServedFolder::new(&scratch.0)
.map_err(anyhow::Error::from)?
.mounted(&Mount::Latest.path(id));
let comments = self.comments_of(id, version, folder.clone());
let carrying = comments.clone();
let entry_file = folder.path().join(entry);
let carry = tokio::task::spawn_blocking(move || {
let version = Folder::new(Arc::new(Document::load(&entry_file, &folder)?));
carrying.carry(&version)
})
.await
.map_err(anyhow::Error::from)??;
metrics::timed(Stage::Upload, parts.bucket.put_version(id, version, zip)).await?;
let recorded = parts.store.record(
id,
version,
&Publish {
entry,
kind,
title: &title,
author,
size,
sha256: &sha256,
},
&carry,
);
if let Err(error) = recorded {
let _ = parts.bucket.delete_version(id, version).await;
return Err(error.into());
}
eprintln!("published {id} v{version} by {author}");
comments.carried(carry);
self.announce(id);
Ok((id.to_string(), version))
}
pub async fn delete(&self, id: &str, deletion: Deletion, author: &str) -> Result<(), Refusal> {
let parts = &self.0;
let _one_at_a_time = parts.locks.hold(id).await;
let (numbers, gone) = match deletion {
Deletion::Document => (parts.store.delete(id)?.ok_or(Refusal::NotFound)?, true),
Deletion::Version(n) => match parts.store.delete_version(id, n)? {
Deleted::Nothing => return Err(Refusal::NotFound),
Deleted::Version => (vec![n], false),
Deleted::Document => (vec![n], true),
},
};
for &n in &numbers {
parts.bucket.delete_version(id, n).await?;
parts.cache.remove(&(id.to_string(), n));
}
if gone {
parts.cache.remove_document(id);
}
self.announce(id);
if gone {
lock(&parts.buses).remove(id);
}
match deletion {
Deletion::Document => eprintln!("deleted {id} by {author}"),
Deletion::Version(n) => eprintln!("deleted {id} v{n} by {author}"),
}
Ok(())
}
pub fn list(&self) -> Result<Vec<DocumentRow>, Refusal> {
Ok(self.0.store.list()?)
}
pub fn get(&self, id: &str) -> Result<(DocumentRow, Vec<VersionRow>), Refusal> {
self.0.store.get(id)?.ok_or(Refusal::NotFound)
}
pub fn threads(&self, id: &str) -> Result<Vec<ThreadRow>, Refusal> {
self.0.store.latest(id)?.ok_or(Refusal::NotFound)?;
Ok(self.0.store.threads(id)?)
}
pub async fn version(&self, id: &str, mount: Mount) -> Result<Version, Refusal> {
let parts = &self.0;
let number = match mount {
Mount::Latest => parts.store.latest(id)?.ok_or(Refusal::NotFound)?,
Mount::Pinned(n) => n,
};
let entry = parts.store.entry(id, number)?.ok_or(Refusal::NotFound)?;
let key = (id.to_string(), number);
let at = mount.path(id);
let fetch = metrics::timed(Stage::Fetch, parts.bucket.get_version(id, number));
let served = parts
.cache
.router(&key, &at, fetch, |dir: &Path| {
let _made = Made(std::time::Instant::now());
self.router(id, number, mount, dir, &entry)
})
.await?;
Ok(Version {
id: id.to_string(),
number,
mount,
served,
})
}
pub fn comments(&self, version: &Version) -> Result<Comments, Refusal> {
let folder = ServedFolder::new(&version.served.folder)
.map_err(anyhow::Error::from)?
.mounted(&version.mount.path(&version.id));
Ok(self.comments_of(&version.id, version.number, folder))
}
pub async fn download(&self, id: &str, n: u64) -> Result<Option<Download>, Refusal> {
let (_, versions) = match self.0.store.get(id)? {
Some(found) => found,
None => return Ok(None),
};
let Some(row) = versions.into_iter().find(|v| v.number == n) else {
return Ok(None);
};
let Some((extension, media)) = devpage_core::document::downloads_as(&row.kind) else {
return Ok(None);
};
let version = self.version(id, Mount::Pinned(n)).await?;
let entry = self.0.store.entry(id, n)?.ok_or(Refusal::NotFound)?;
let designs = self.0.designs.clone();
let mount = Mount::Pinned(n).path(id);
let bytes = self
.0
.cache
.download(&version.served, extension, |folder| async move {
metrics::timed(
Stage::Download,
render(&designs, &mount, &folder, &entry, extension),
)
.await
})
.await?;
let name = devpage_core::document::download_name(&row.title, extension)
.to_string_lossy()
.into_owned();
Ok(Some(Download { bytes, media, name }))
}
fn bus(&self, id: &str) -> Changes {
lock(&self.0.buses)
.entry(id.to_string())
.or_default()
.clone()
}
fn announce(&self, id: &str) {
self.bus(id).publish(Change::Refresh);
}
fn comments_of(&self, id: &str, version: u64, folder: ServedFolder) -> Comments {
Comments::new(
Arc::new(DocumentThreads {
store: self.0.store.clone(),
document: id.to_string(),
version,
}),
Arc::new(self.bus(id)),
folder,
)
}
fn preview(&self, id: &str, n: u64) -> Arc<Preview> {
let mut previews = lock(&self.0.previews);
previews.retain(|_, preview| preview.strong_count() > 0);
let key = (id.to_string(), n);
if let Some(preview) = previews.get(&key).and_then(Weak::upgrade) {
return preview;
}
let preview = Arc::new(Preview::once(
crate::adapters::external::offline(),
Arc::new(crate::adapters::terminal::Terminal),
));
previews.insert(key, Arc::downgrade(&preview));
preview
}
fn router(&self, id: &str, n: u64, mount: Mount, dir: &Path, entry: &str) -> Result<Router> {
let served = ServedFolder::new(dir)?.mounted(&mount.path(id));
let document = Arc::new(Document::load(&served.path().join(entry), &served)?);
let designs = self.0.designs.designs(&Mount::Pinned(n).path(id), &served);
let (searches, key, at, file) = (
self.0.searches.clone(),
(id.to_string(), n),
mount.path(id),
document.path(),
);
let search: http::OnDemand = Arc::new(move |folder: &Folder, query: &str| {
searches.answer(&key, &at, query, || {
devpage_core::search::Search::frozen(folder, &file)
})
});
let preview = self.preview(id, n);
let app = if mount == Mount::Latest {
let comments = Commenting::new(
self.comments_of(id, n, served.clone()),
self.0.identity.clone(),
);
App::published(
document,
designs,
self.bus(id),
Some(comments),
search,
preview,
)
} else {
App::published(document, designs, Changes::default(), None, search, preview)
};
Ok(http::router(app))
}
}
async fn render(
designs: &designs::Store,
mount: &str,
folder: &Path,
entry: &str,
extension: &str,
) -> Result<Vec<u8>> {
let served = ServedFolder::new(folder)?;
if extension == "png" {
let designs = designs.designs(mount, &served);
let entry = entry.to_string();
return tokio::task::spawn_blocking(move || designs.png(&entry, Theme::Light))
.await?
.map_err(anyhow::Error::msg);
}
let document = Document::load(&served.path().join(entry), &served)?;
crate::drive::pdf::print(Arc::new(document)).await
}
async fn check(
store: &designs::Store,
zip: &[u8],
entry: &str,
scratch: &Path,
) -> Result<(&'static str, String)> {
bundle::unpack(zip, scratch)?;
let scratch = ServedFolder::new(scratch)?;
let file = scratch.path().join(entry);
if !file.is_file() {
bail!("{entry}: no such file in the zip");
}
let folder = Folder::new(Arc::new(Document::load(&file, &scratch)?));
let designs = store.designs(&format!("scratch:{}", scratch.path().display()), &scratch);
let problems = devpage_core::check::document(&folder, &designs, None).await;
let document = folder.entry();
if !problems.is_empty() {
let lines: Vec<String> = problems.iter().map(ToString::to_string).collect();
bail!("{}", lines.join("\n"));
}
let kind = match document.kind {
Kind::Site { .. } => "site",
Kind::Doc { .. } => "doc",
Kind::Slides { .. } => "slides",
Kind::Design => "design",
};
Ok((kind, document.title.clone()))
}
struct Scratch(PathBuf);
impl Scratch {
fn new(under: &Path) -> Result<Scratch> {
let folder = under.join(uuid::Uuid::new_v4().simple().to_string());
std::fs::create_dir_all(&folder)?;
Ok(Scratch(folder))
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[derive(Default)]
struct Locks(Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>);
struct Held<'a> {
locks: &'a Locks,
id: String,
guard: Option<tokio::sync::OwnedMutexGuard<()>>,
}
impl Locks {
async fn hold(&self, id: &str) -> Held<'_> {
let mutex = lock(&self.0).entry(id.to_string()).or_default().clone();
Held {
locks: self,
id: id.to_string(),
guard: Some(mutex.lock_owned().await),
}
}
}
impl Drop for Held<'_> {
fn drop(&mut self) {
drop(self.guard.take());
let mut locks = lock(&self.locks.0);
if locks
.get(&self.id)
.is_some_and(|mutex| Arc::strong_count(mutex) == 1)
{
locks.remove(&self.id);
}
}
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|p| p.into_inner())
}
struct Made(std::time::Instant);
impl Drop for Made {
fn drop(&mut self) {
metrics::observe(Stage::Router, self.0.elapsed());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::drive::server_mode::tests::{config, site};
#[tokio::test]
async fn a_deleted_document_leaves_nothing_behind() {
let published = Published::open(&config("prune")).unwrap();
let one = || site("Plan", "one");
let (id, _) = published
.publish(None, "devpage.yml", "ana", one())
.await
.unwrap();
published
.publish(Some(&id), "devpage.yml", "ana", one())
.await
.unwrap();
drop(published.version(&id, Mount::Latest).await.unwrap());
assert!(lock(&published.0.buses).contains_key(&id));
assert!(lock(&published.0.locks.0).is_empty());
published
.delete(&id, Deletion::Version(1), "ana")
.await
.unwrap();
assert!(
lock(&published.0.buses).contains_key(&id),
"it has version 2"
);
published
.delete(&id, Deletion::Version(2), "ana")
.await
.unwrap();
assert!(
lock(&published.0.buses).is_empty(),
"its only version took it"
);
assert!(lock(&published.0.locks.0).is_empty());
assert!(matches!(
published.delete(&id, Deletion::Document, "ana").await,
Err(Refusal::NotFound)
));
}
#[test]
fn a_mount_is_where_its_version_is_served() {
let id = "8b1f";
assert_eq!(Mount::Latest.path(id), "/d/8b1f");
assert_eq!(Mount::Pinned(3).path(id), "/d/8b1f/v/3");
}
}