mod canvas;
#[cfg(test)]
mod canvas_tests;
#[cfg(test)]
mod comments_browser;
#[cfg(test)]
pub(crate) mod design_tests;
mod watch_stream;
use std::convert::Infallible;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::Json;
use axum::Router;
use axum::extract::Query;
use axum::extract::{Path, RawQuery, Request, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use serde::Deserialize;
use crate::changes::{Changes, Scope};
use crate::check::Preview;
use crate::comments::{self, Anchor};
use crate::designs::Designs;
use crate::document::{Document, Entry};
use crate::index::Index;
use crate::resolve::{Answer, Resolver};
use crate::slide_png::Shots;
#[derive(Clone)]
struct AppState {
entry: Arc<Entry>,
index: Arc<Index>,
changes: Changes,
comments: Option<Arc<Commenting>>,
designs: Designs,
design_updates: tokio::sync::broadcast::Sender<String>,
shots: Shots,
preview: Arc<Preview>,
}
struct Commenting {
threads: comments::Comments,
identity: Identity,
}
impl AppState {
#[allow(clippy::result_large_err)]
fn comments(&self) -> Result<&Commenting, Response> {
self.comments
.as_deref()
.ok_or_else(|| (StatusCode::NOT_FOUND, "comments are off").into_response())
}
fn comments_on(&self) -> bool {
self.comments.is_some()
}
}
pub enum Identity {
Local(String),
Header(String),
}
impl Identity {
#[allow(clippy::result_large_err)]
fn author(&self, headers: &HeaderMap) -> Result<String, Response> {
match self {
Identity::Local(name) => Ok(name.clone()),
Identity::Header(name) => headers
.get(name.as_str())
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| {
(StatusCode::UNAUTHORIZED, format!("no {name} header")).into_response()
}),
}
}
}
pub struct Options {
pub entry: Arc<Entry>,
pub index: Arc<Index>,
pub designs: Designs,
pub changes: Changes,
pub comments: Option<Identity>,
pub threads: Arc<comments::yaml::Yaml>,
pub store: Arc<dyn comments::Store>,
pub shots: Shots,
pub preview: Arc<Preview>,
pub log: bool,
}
impl Options {
pub fn new(document: Arc<Document>, changes: Changes) -> Self {
let threads = Arc::new(comments::yaml::Yaml::new(&document.served));
Self {
index: Arc::new(Index::build(&document.served)),
designs: Designs::new(&document.served),
shots: Shots::new(&changes),
preview: Arc::new(Preview::new(
crate::http::external(Some(changes.clone())),
Arc::new(crate::terminal::Terminal),
)),
store: threads.clone(),
threads,
entry: Arc::new(Entry::new(document)),
comments: Some(Identity::Local(String::new())),
changes,
log: false,
}
}
pub fn printing(document: Arc<Document>, changes: Changes) -> Self {
Self {
preview: Arc::new(Preview::new(
crate::http::external(None),
Arc::new(crate::terminal::Terminal),
)),
..Self::new(document, changes)
}
}
}
impl Options {
pub fn watch(&self) -> anyhow::Result<crate::watch::Watch> {
crate::watch::start(
self.entry.clone(),
self.index.clone(),
self.designs.clone(),
self.changes.clone(),
self.threads.clone(),
)
}
}
pub fn router(options: Options) -> Router {
let Options {
entry,
index,
designs,
changes,
comments,
threads: _,
store,
shots,
preview,
log,
} = options;
let router = routes(AppState {
comments: comments.map(|identity| {
Arc::new(Commenting {
threads: comments::Comments::new(store, Arc::new(changes.clone()), Scope::root()),
identity,
})
}),
design_updates: watch_stream::hear(&designs),
designs,
entry,
index,
changes,
shots,
preview,
});
if log {
router.layer(middleware::from_fn(log_request))
} else {
router
}
}
fn routes(state: AppState) -> Router {
let designs = state.designs.clone();
Router::new()
.route("/", get(home))
.route(
"/__devpage/comments",
get(list_comments).post(create_comment),
)
.route("/__devpage/comments/reply", post(reply_comment))
.route("/__devpage/comments/resolve", post(resolve_comment))
.route("/__devpage/search", get(search))
.route("/__devpage/system-widgets/{name}", get(system_widget))
.route("/__devpage/widgets/{*path}", get(project_widget))
.route("/__devpage/{asset}", get(asset))
.route("/{*path}", get(file))
.route_layer(middleware::from_fn(crate::validators::validate))
.route("/__devpage/events", get(events))
.route_service(
"/devpage.design.v1.Designs/Watch",
tower::ServiceBuilder::new()
.layer(tonic_web::GrpcWebLayer::new())
.service(devpage_design::proto::designs_server::DesignsServer::new(
watch_stream::Watch::new(designs.clone(), state.design_updates.clone()),
)),
)
.with_state(state)
}
async fn log_request(request: Request, next: Next) -> Response {
let (method, path) = (request.method().clone(), request.uri().path().to_string());
let started = Instant::now();
let response = next.run(request).await;
eprintln!(
"{method} {path} → {} in {:.1} ms",
response.status().as_u16(),
started.elapsed().as_secs_f64() * 1000.0
);
response
}
fn resolver(state: &AppState) -> Resolver<'_> {
Resolver {
entry: &state.entry,
index: &state.index,
preview: &state.preview,
comments: state.comments_on(),
}
}
async fn home(State(state): State<AppState>, headers: HeaderMap) -> Response {
let answer = resolver(&state).home();
respond(&state, &headers, answer).await
}
async fn file(
State(state): State<AppState>,
Path(path): Path<String>,
RawQuery(query): RawQuery,
headers: HeaderMap,
) -> Response {
let answer = resolver(&state).path(&path, query.as_deref());
respond(&state, &headers, answer).await
}
async fn respond(state: &AppState, headers: &HeaderMap, answer: Answer) -> Response {
match answer {
Answer::Html(html) => Html(html).into_response(),
Answer::Redirect(address) => Redirect::temporary(&address).into_response(),
Answer::NotFound(why) => (StatusCode::NOT_FOUND, why).into_response(),
Answer::BadRequest(why) => (StatusCode::BAD_REQUEST, why).into_response(),
Answer::Failed(why) => (StatusCode::INTERNAL_SERVER_ERROR, why).into_response(),
Answer::File(file) => match tokio::fs::read(&file).await {
Ok(bytes) => {
let mime = mime_guess::from_path(&file).first_or_octet_stream();
([(header::CONTENT_TYPE, mime.to_string())], bytes).into_response()
}
Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(),
},
Answer::Canvas { design, theme } => {
Html(canvas::page(&state.entry.document().served, &design, theme)).into_response()
}
Answer::DesignPng { design, theme } => design_png(state, headers, design, theme).await,
Answer::SlidePng { page, n } => slide_png(state, headers, &page, n).await,
}
}
async fn design_png(
state: &AppState,
headers: &HeaderMap,
design: String,
theme: devpage_design::Theme,
) -> Response {
let designs = state.designs.clone();
let path = design.clone();
let tag = tokio::task::spawn_blocking(move || designs.etag(&path, theme))
.await
.ok()
.flatten()
.map(|hash| crate::validators::strong(&hash));
if let Some(tag) = &tag
&& crate::validators::already_has(headers, tag)
{
let mut known = HeaderMap::new();
if let Ok(value) = tag.parse() {
known.insert(header::ETAG, value);
}
known.insert(
header::CACHE_CONTROL,
header::HeaderValue::from_static("no-cache"),
);
return crate::validators::not_modified(known);
}
let designs = state.designs.clone();
let (first, started) = tokio::sync::oneshot::channel::<Result<(), String>>();
let (tx, rx) = tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::io::Error>>(4);
tokio::task::spawn_blocking(move || {
struct Sink {
tx: tokio::sync::mpsc::Sender<Result<axum::body::Bytes, std::io::Error>>,
first: Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
}
impl std::io::Write for Sink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if let Some(first) = self.first.take() {
let _ = first.send(Ok(()));
}
self.tx
.blocking_send(Ok(axum::body::Bytes::copy_from_slice(buf)))
.map_err(|_| std::io::Error::other("the client went away"))?;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut sink = Sink {
tx,
first: Some(first),
};
let result = designs.png_to(&design, theme, &mut sink);
if let Some(first) = sink.first.take() {
let _ = first.send(result);
}
});
match started
.await
.unwrap_or_else(|_| Err("painting stopped".into()))
{
Ok(()) => {
let body =
axum::body::Body::from_stream(tokio_stream::wrappers::ReceiverStream::new(rx));
let mut response = ([(header::CONTENT_TYPE, "image/png")], body).into_response();
if let Some(tag) = tag.and_then(|tag| tag.parse().ok()) {
response.headers_mut().insert(header::ETAG, tag);
}
response
}
Err(problems) => (StatusCode::UNPROCESSABLE_ENTITY, problems).into_response(),
}
}
async fn slide_png(state: &AppState, headers: &HeaderMap, page: &str, n: usize) -> Response {
let shot = if headers.contains_key(crate::pdf::SHOT_HEADER) {
match state.shots.cached(page, n) {
Some(shot) => shot,
None => {
return (
StatusCode::LOOP_DETECTED,
format!("{}/{n}.png isn't drawn inside another shot", &page[1..]),
)
.into_response();
}
}
} else {
let serving = state.clone();
match state.shots.png(page, n, move || routes(serving)).await {
Ok(shot) => shot,
Err(error) => {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{error:#}")).into_response();
}
}
};
let held = headers
.get(header::IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
.is_some_and(|tags| tags.split(',').any(|tag| tag.trim() == shot.etag));
let mut response = if held {
StatusCode::NOT_MODIFIED.into_response()
} else {
([(header::CONTENT_TYPE, "image/png")], shot.png).into_response()
};
let answer = response.headers_mut();
if let Ok(etag) = shot.etag.parse() {
answer.insert(header::ETAG, etag);
}
if !shot.ready {
answer.insert(
"x-devpage-ready",
header::HeaderValue::from_static("timeout"),
);
}
response
}
async fn events(
State(state): State<AppState>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let heard = state.changes.subscribe(Scope::root());
let stream = futures::stream::unfold(heard, |mut heard| async move {
let change = heard.next().await?;
Some((Ok(Event::default().data(change.message())), heard))
});
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
}
fn asked_version(query: Option<&str>) -> Option<&str> {
query?.split('&').find_map(|pair| pair.strip_prefix("v="))
}
async fn system_widget(
State(state): State<AppState>,
Path(name): Path<String>,
RawQuery(query): RawQuery,
) -> Response {
let stem = name.trim_end_matches(".tsx");
match state.index.modules().system(stem) {
Some(module) => {
let current = crate::addresses::system_widget_hash(stem)
.is_some_and(|hash| asked_version(query.as_deref()) == Some(hash));
compiled(&name, module, current)
}
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn project_widget(
State(state): State<AppState>,
Path(path): Path<String>,
RawQuery(query): RawQuery,
) -> Response {
let document = state.entry.document();
let found = ["", ".tsx", ".ts"]
.iter()
.find_map(|ext| document.file(&format!("{path}{ext}")))
.filter(|file| file.is_file());
let Some(file) = found else {
return (
StatusCode::NOT_FOUND,
format!("{path} is not part of the served folder"),
)
.into_response();
};
let folder = file.parent().unwrap_or(&file).to_path_buf();
let known = known(&state, &folder);
let snapshot = state.index.modules().snapshot(&document.served, &file);
let current = asked_version(query.as_deref()) == Some(snapshot.hash(&file, &known).as_str());
if let Some(error) = broken_import(&document, &snapshot, &file, &known) {
return module_error(&error);
}
let label = document.name(&file);
compiled(&label, snapshot.compile(&file, &known), current)
}
fn known(state: &AppState, folder: &std::path::Path) -> Vec<String> {
let entry = state.entry.document();
let owner = if entry.widgets.as_deref() == Some(folder) {
None
} else {
state
.index
.documents()
.into_iter()
.filter_map(|file| crate::document::Document::load(&file, &entry.served).ok())
.find(|document| document.widgets.as_deref() == Some(folder))
};
crate::widgets::Widgets::of(owner.as_ref().unwrap_or(&entry), state.index.modules())
.modules
.into_keys()
.collect()
}
fn broken_import(
document: &crate::document::Document,
snapshot: &crate::widgets::Snapshot,
file: &std::path::Path,
known: &[String],
) -> Option<String> {
snapshot
.closure(file)
.into_iter()
.skip(1)
.find_map(|(helper, _)| {
snapshot
.compile(&helper, known)
.err()
.map(|error| format!("{} does not compile: {error}", document.name(&helper)))
})
}
fn module_error(message: &str) -> Response {
(
[
(header::CONTENT_TYPE, "text/javascript"),
(header::CACHE_CONTROL, "no-cache"),
],
format!(
"throw new Error({});",
serde_json::to_string(message).unwrap_or_default()
),
)
.into_response()
}
const IMMUTABLE: &str = "public, max-age=31536000, immutable";
fn compiled(label: &str, result: anyhow::Result<String>, current: bool) -> Response {
match result {
Ok(js) => (
[
(header::CONTENT_TYPE, "text/javascript"),
(
header::CACHE_CONTROL,
if current { IMMUTABLE } else { "no-cache" },
),
],
js,
)
.into_response(),
Err(error) => module_error(&format!("{label} does not compile: {error}")),
}
}
pub async fn asset(Path(name): Path<String>, headers: HeaderMap) -> Response {
let Some((asset, current)) = crate::addresses::asset(&name) else {
return StatusCode::NOT_FOUND.into_response();
};
let encoding =
crate::validators::encoding(&headers, asset.brotli.is_some(), asset.gzip.is_some());
let (body, tag) = match encoding {
Some("br") => (asset.brotli, format!("{}-br", asset.hash)),
Some(_) => (asset.gzip, format!("{}-gz", asset.hash)),
None => (Some(asset.identity), asset.hash.to_string()),
};
let mut response = (
[
(header::CONTENT_TYPE, asset.mime.to_string()),
(
header::CACHE_CONTROL,
if current { IMMUTABLE } else { "no-cache" }.to_string(),
),
(header::VARY, "Accept-Encoding".to_string()),
(header::ETAG, crate::validators::strong(&tag)),
],
body.unwrap_or(asset.identity),
)
.into_response();
if let Some(encoding) = encoding {
response.headers_mut().insert(
header::CONTENT_ENCODING,
header::HeaderValue::from_static(encoding),
);
}
response
}
#[derive(Deserialize)]
struct PageQuery {
page: String,
}
#[derive(Deserialize)]
struct NewThread {
page: String,
anchor: Anchor,
text: String,
}
#[derive(Deserialize)]
struct Reply {
page: String,
id: String,
#[serde(default)]
text: String,
}
fn failed(error: anyhow::Error) -> Response {
(StatusCode::BAD_REQUEST, format!("{error:#}")).into_response()
}
#[derive(Deserialize)]
struct SearchQuery {
#[serde(default)]
q: String,
}
async fn search(State(state): State<AppState>, Query(query): Query<SearchQuery>) -> Response {
let answer = state
.index
.search()
.map(|search| search.search(&query.q))
.unwrap_or_default();
Json(answer).into_response()
}
async fn list_comments(State(state): State<AppState>, Query(query): Query<PageQuery>) -> Response {
let comments = match state.comments() {
Ok(comments) => comments,
Err(off) => return off,
};
match comments.threads.list(&query.page) {
Ok(threads) => Json(threads).into_response(),
Err(error) => failed(error),
}
}
async fn create_comment(
State(state): State<AppState>,
headers: HeaderMap,
Json(new): Json<NewThread>,
) -> Response {
let comments = match state.comments() {
Ok(comments) => comments,
Err(off) => return off,
};
let author = match comments.identity.author(&headers) {
Ok(author) => author,
Err(refused) => return refused,
};
if state
.entry
.document()
.file(&new.page)
.is_none_or(|file| !file.is_file())
{
return (
StatusCode::BAD_REQUEST,
format!("{} is not a page in the served folder", new.page),
)
.into_response();
}
match comments
.threads
.create(&new.page, new.anchor, &author, &new.text)
{
Ok(id) => Json(serde_json::json!({ "id": id })).into_response(),
Err(error) => failed(error),
}
}
async fn reply_comment(
State(state): State<AppState>,
headers: HeaderMap,
Json(reply): Json<Reply>,
) -> Response {
let comments = match state.comments() {
Ok(comments) => comments,
Err(off) => return off,
};
let author = match comments.identity.author(&headers) {
Ok(author) => author,
Err(refused) => return refused,
};
match comments
.threads
.reply(&reply.page, &reply.id, &author, &reply.text)
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(error) => failed(error),
}
}
async fn resolve_comment(
State(state): State<AppState>,
headers: HeaderMap,
Json(reply): Json<Reply>,
) -> Response {
let comments = match state.comments() {
Ok(comments) => comments,
Err(off) => return off,
};
if let Err(refused) = comments.identity.author(&headers) {
return refused;
}
match comments.threads.resolve(&reply.page, &reply.id) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(error) => failed(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
async fn serve_comments(
name: &str,
comments: Option<Identity>,
) -> (String, std::path::PathBuf) {
let (address, root, _) = serve_comments_on(name, comments).await;
(address, root)
}
async fn serve_comments_on(
name: &str,
comments: Option<Identity>,
) -> (String, std::path::PathBuf, crate::changes::Changes) {
let root =
std::env::temp_dir().join(format!("devpage-routes-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("a.md"),
"# A\n\nThe client retries every write.\n",
)
.unwrap();
let document = Arc::new(Document::load(&root.join("a.md"), &root).unwrap());
let changes = crate::changes::Changes::default();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
let router = router(Options {
comments,
store: Arc::new(comments::memory::Memory::default()),
preview: crate::check::testing::preview(),
..Options::new(document, changes.clone())
});
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
(address, root, changes)
}
fn new_thread() -> Value {
json!({
"page": "/a.md",
"anchor": { "quote": "retries", "prefix": "The client ", "suffix": " every write." },
"text": "Every write?",
})
}
#[tokio::test]
async fn a_thread_is_created_replied_to_and_resolved_over_http() {
let (address, root) = serve_comments("life", Some(Identity::Local("Ana".into()))).await;
let client = reqwest::Client::new();
let created: Value = client
.post(format!("{address}/__devpage/comments"))
.json(&new_thread())
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let id = created["id"].as_str().unwrap().to_string();
let reply = client
.post(format!("{address}/__devpage/comments/reply"))
.json(&json!({ "page": "/a.md", "id": id, "text": "Only the outbox." }))
.send()
.await
.unwrap();
assert_eq!(reply.status(), 204);
let listed: Value = client
.get(format!("{address}/__devpage/comments?page=/a.md"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(listed[0]["id"], id.as_str());
assert_eq!(listed[0]["anchor"]["quote"], "retries");
assert_eq!(listed[0]["thread"][0]["author"], "Ana");
assert_eq!(listed[0]["thread"][1]["text"], "Only the outbox.");
assert!(!root.join(".devpage").exists(), "kept in memory");
let resolved = client
.post(format!("{address}/__devpage/comments/resolve"))
.json(&json!({ "page": "/a.md", "id": id }))
.send()
.await
.unwrap();
assert_eq!(resolved.status(), 204);
let listed: Value = client
.get(format!("{address}/__devpage/comments?page=/a.md"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(listed, json!([]));
}
#[tokio::test]
async fn a_server_and_the_watcher_its_options_start_announce_a_write_once() {
use crate::changes::{Change, Scope};
let dir = std::env::temp_dir().join(format!("devpage-paired-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
std::fs::write(dir.join("a.md"), "# A\n\nThe client retries every write.\n").unwrap();
let document = Arc::new(Document::load(&dir.join("a.md"), &dir).unwrap());
let changes = crate::changes::Changes::default();
let options = Options {
preview: crate::check::testing::preview(),
..Options::new(document, changes.clone())
};
let _watcher = options.watch().unwrap();
let router = router(options);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, router).await.ok() });
tokio::time::sleep(Duration::from_millis(300)).await;
let mut heard = changes.subscribe(Scope::root());
let created = reqwest::Client::new()
.post(format!("{address}/__devpage/comments"))
.json(&new_thread())
.send()
.await
.unwrap();
assert_eq!(created.status(), 200);
tokio::time::sleep(Duration::from_millis(800)).await;
let page = Change::Comments("/a.md".into());
let announced = heard.drain().into_iter().filter(|c| *c == page).count();
assert_eq!(announced, 1, "the server's announcement, and no echo");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn each_write_reaches_open_pages_without_a_watcher() {
use crate::changes::{Change, Scope};
let (address, _, changes) =
serve_comments_on("announce", Some(Identity::Local("Ana".into()))).await;
let mut heard = changes.subscribe(Scope::root());
let client = reqwest::Client::new();
let created: Value = client
.post(format!("{address}/__devpage/comments"))
.json(&new_thread())
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let id = created["id"].as_str().unwrap();
for action in ["reply", "resolve"] {
let done = client
.post(format!("{address}/__devpage/comments/{action}"))
.json(&json!({ "page": "/a.md", "id": id, "text": "x" }))
.send()
.await
.unwrap();
assert_eq!(done.status(), 204, "{action}");
}
let refused = client
.post(format!("{address}/__devpage/comments/reply"))
.json(&json!({ "page": "/a.md", "id": id, "text": "gone" }))
.send()
.await
.unwrap();
assert_eq!(refused.status(), 400);
client
.get(format!("{address}/__devpage/comments?page=/a.md"))
.send()
.await
.unwrap();
let page = Change::Comments("/a.md".into());
assert_eq!(heard.drain(), [page.clone(), page.clone(), page]);
}
#[tokio::test]
async fn deployed_writes_need_the_header_and_are_stamped_with_it() {
let identity = Identity::Header("X-Forwarded-Email".into());
let (address, _) = serve_comments("deployed", Some(identity)).await;
let client = reqwest::Client::new();
let refused = client
.post(format!("{address}/__devpage/comments"))
.json(&new_thread())
.send()
.await
.unwrap();
assert_eq!(refused.status(), 401);
let created: Value = client
.post(format!("{address}/__devpage/comments"))
.header("X-Forwarded-Email", "ana@example.com")
.json(&new_thread())
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let id = created["id"].as_str().unwrap();
for action in ["reply", "resolve"] {
let refused = client
.post(format!("{address}/__devpage/comments/{action}"))
.json(&json!({ "page": "/a.md", "id": id, "text": "x" }))
.send()
.await
.unwrap();
assert_eq!(refused.status(), 401, "{action} without the header");
}
let listed: Value = client
.get(format!("{address}/__devpage/comments?page=/a.md"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(listed[0]["thread"][0]["author"], "ana@example.com");
}
#[tokio::test]
async fn a_thread_on_a_page_that_isnt_in_the_served_folder_is_refused() {
let (address, _) = serve_comments("outside", Some(Identity::Local("Ana".into()))).await;
let mut thread = new_thread();
for page in ["/../escape.md", "/missing.md"] {
thread["page"] = json!(page);
let refused = reqwest::Client::new()
.post(format!("{address}/__devpage/comments"))
.json(&thread)
.send()
.await
.unwrap();
assert_eq!(refused.status(), 400, "{page}");
}
}
#[tokio::test]
async fn with_comments_off_every_comment_route_is_404_and_nothing_is_written() {
let (address, root) = serve_comments("off", None).await;
let client = reqwest::Client::new();
let listed = client
.get(format!("{address}/__devpage/comments?page=/a.md"))
.send()
.await
.unwrap();
assert_eq!(listed.status(), 404);
let created = client
.post(format!("{address}/__devpage/comments"))
.json(&new_thread())
.send()
.await
.unwrap();
assert_eq!(created.status(), 404);
for action in ["reply", "resolve"] {
let refused = client
.post(format!("{address}/__devpage/comments/{action}"))
.json(&json!({ "page": "/a.md", "id": "x", "text": "hi" }))
.send()
.await
.unwrap();
assert_eq!(refused.status(), 404, "{action}");
}
assert!(!root.join(".devpage").exists());
let page = client
.get(format!("{address}/a.md"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(page.contains("data-no-comments"), "{page}");
assert!(!page.contains("comment-mode"), "{page}");
}
#[test]
fn deployed_authors_come_from_the_proxy_header_or_are_refused() {
let deployed = Identity::Header("X-Forwarded-Email".into());
let mut headers = HeaderMap::new();
let refused = deployed.author(&headers).unwrap_err();
assert_eq!(refused.status(), StatusCode::UNAUTHORIZED);
headers.insert("x-forwarded-email", "ana@example.com".parse().unwrap());
assert_eq!(deployed.author(&headers).unwrap(), "ana@example.com");
let local = Identity::Local("Andrei".into());
assert_eq!(local.author(&headers).unwrap(), "Andrei");
}
async fn serve(dir: &std::path::Path, entry: &str) -> String {
let document = Arc::new(Document::load(&dir.join(entry), dir).unwrap());
let changes = crate::changes::Changes::default();
let router = router(Options {
comments: Some(Identity::Local("T".into())),
..Options::new(document, changes)
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, router).await.ok() });
address
}
#[tokio::test]
async fn a_search_answers_pages_as_json_whether_comments_are_on_or_off() {
let dir = std::env::temp_dir().join(format!("devpage-search-route-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
std::fs::write(
dir.join("devpage.yml"),
"kind: site\ntitle: T\nroot: a.md\n",
)
.unwrap();
std::fs::write(dir.join("a.md"), "# A\n\n## Topics\n\n- [Retries](b.md)\n").unwrap();
std::fs::write(
dir.join("b.md"),
"# Retries\n\n## The outbox\n\nThe client retries every write & read from its outbox.\n",
)
.unwrap();
let document = Arc::new(Document::load(&dir, &dir).unwrap());
let changes = crate::changes::Changes::default();
let router = router(Options {
index: Arc::new(Index::build(&dir).with_search(&dir.join("devpage.yml"))),
comments: None,
preview: crate::check::testing::preview(),
..Options::new(document, changes)
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, router).await.ok() });
let search = |q: &str| {
let url = format!("{address}/__devpage/search?q={q}");
async move {
let response = reqwest::get(url).await.unwrap();
assert_eq!(response.status(), 200);
response.json::<Value>().await.unwrap()
}
};
assert_eq!(
search("outbx").await,
json!({ "pages": [{
"title": "Retries",
"url": "/b.md",
"trail": ["Retries"],
"document": { "title": "T", "path": "/devpage.yml" },
"kind": "page",
"title_match": false,
"lede": "The client retries every write & read from its outbox.",
"sections": [{
"heading": "The outbox",
"path": ["Retries", "The outbox"],
"url": "/b.md#the-outbox",
"snippet": "The client retries every write & read from its <mark>outbox</mark>.",
"words": ["outbox"],
}],
"more": 0,
}] })
);
assert_eq!(search("%20%20").await, json!({ "pages": [] }));
assert_eq!(search("").await, json!({ "pages": [] }));
let (status, _) = get(&format!("{address}/__devpage/search")).await;
assert_eq!(status, 200, "no q is an empty query");
}
async fn get(url: &str) -> (u16, String) {
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let response = client.get(url).send().await.unwrap();
let status = response.status().as_u16();
let location = response
.headers()
.get("location")
.map(|value| value.to_str().unwrap().to_string());
let body = response.text().await.unwrap();
(status, location.unwrap_or(body))
}
fn served(name: &str) -> std::path::PathBuf {
let top =
std::env::temp_dir().join(format!("devpage-server-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&top);
let dir = top.join("served");
std::fs::create_dir_all(dir.join("docs/guide")).unwrap();
std::fs::create_dir_all(dir.join("notes")).unwrap();
std::fs::write(top.join("secret.md"), "# Secret\n").unwrap();
std::fs::write(dir.join("docs/README.md"), "# Home\n").unwrap();
std::fs::write(dir.join("docs/guide/step.md"), "# Step\n").unwrap();
std::fs::write(
dir.join("docs/devpage.yml"),
"kind: site\ntitle: Docs\nroot: README.md\n",
)
.unwrap();
std::fs::write(
dir.join("notes/talk.yml"),
"kind: slides\ntitle: Talk\nslides: [one.md]\n",
)
.unwrap();
std::fs::write(dir.join("notes/one.md"), "# One\n").unwrap();
std::fs::write(dir.join("notes/loose.md"), "# Loose\n").unwrap();
std::fs::write(
dir.join("notes/broken.yml"),
"kind: doc\ntitle: B\nchapters: [gone.md]\n",
)
.unwrap();
std::fs::write(dir.join("notes/ci.yml"), "on: push\n").unwrap();
dir.canonicalize().unwrap()
}
#[tokio::test]
async fn nothing_outside_the_served_folder_is_served() {
let dir = served("outside");
std::os::unix::fs::symlink(dir.join("../secret.md"), dir.join("docs/escape.md")).unwrap();
let base = serve(&dir, "docs").await;
for path in [
"/../secret.md",
"/%2E%2E/secret.md",
"/docs/escape.md",
"/docs/missing.md",
] {
let (status, body) = get(&format!("{base}{path}")).await;
assert_eq!(status, 404, "{path}: {body}");
}
}
async fn home_becomes(address: &str, expected: &str) -> (u16, String) {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
let answer = get(&format!("{address}/")).await;
if answer.1.contains(expected) || Instant::now() > deadline {
return answer;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[tokio::test]
async fn the_entry_document_follows_its_file_without_a_restart() {
let dir = std::env::temp_dir().join(format!("devpage-entry-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
std::fs::write(dir.join("a.md"), "# A").unwrap();
std::fs::write(dir.join("b.md"), "# B").unwrap();
let site = dir.join("devpage.yml");
std::fs::write(&site, "kind: site\ntitle: T\nroot: a.md\n").unwrap();
let document = Arc::new(Document::load(&site, &dir).unwrap());
let index = Arc::new(Index::build(&dir));
let entry = Arc::new(Entry::new(document.clone()));
let changes = crate::changes::Changes::default();
let designs = Designs::new(dir.clone());
let options = Options {
entry,
index,
designs,
comments: Some(Identity::Local("T".into())),
preview: crate::check::testing::preview(),
..Options::new(document, changes.clone())
};
let _watcher = options.watch().unwrap();
let router = router(options);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, router).await.ok() });
assert_eq!(get(&format!("{address}/")).await, (307, "/a.md".into()));
tokio::time::sleep(Duration::from_millis(300)).await;
std::fs::write(&site, "kind: site\ntitle: T\nroot: b.md\n").unwrap();
assert_eq!(home_becomes(&address, "/b.md").await, (307, "/b.md".into()));
std::fs::write(&site, "kind: site\ntitle: T\n").unwrap();
let (status, body) = home_becomes(&address, "not a valid document").await;
assert_eq!(status, 200);
assert!(
body.contains("/devpage.yml is not a valid document"),
"{body}"
);
assert!(body.contains("missing field"), "{body}");
let (_, page) = get(&format!("{address}/a.md")).await;
assert!(page.contains("not a valid document"), "{page}");
std::fs::write(&site, "kind: site\ntitle: T\nroot: a.md\n").unwrap();
assert_eq!(home_becomes(&address, "/a.md").await, (307, "/a.md".into()));
let (_, page) = get(&format!("{address}/a.md")).await;
assert!(!page.contains("not a valid document"), "{page}");
}
#[tokio::test]
async fn widgets_are_served_compiled_for_the_document_they_belong_to() {
let dir = served("widget-routes");
std::fs::create_dir_all(dir.join("docs/widgets")).unwrap();
std::fs::create_dir_all(dir.join("notes/parts")).unwrap();
std::fs::write(
dir.join("docs/devpage.yml"),
"kind: site\ntitle: Docs\nroot: README.md\nwidgets: widgets\n",
)
.unwrap();
std::fs::write(
dir.join("docs/widgets/Card.tsx"),
"import { label } from \"./label\";\nexport default () => <Callout>{label}</Callout>;",
)
.unwrap();
std::fs::write(
dir.join("docs/widgets/label.ts"),
"export const label = 'x';",
)
.unwrap();
std::fs::write(dir.join("docs/widgets/Broken.tsx"), "export default (").unwrap();
std::fs::write(
dir.join("notes/talk.yml"),
"kind: slides\ntitle: Talk\nslides: [one.md]\nwidgets: parts\n",
)
.unwrap();
std::fs::write(
dir.join("notes/parts/Pill.tsx"),
"export default () => <i>p</i>;",
)
.unwrap();
std::fs::write(
dir.join("notes/parts/Row.tsx"),
"export default () => <Pill />;",
)
.unwrap();
let base = serve(&dir, "docs").await;
let (status, system) = get(&format!("{base}/__devpage/system-widgets/Tabs.tsx")).await;
assert_eq!(status, 200);
assert!(system.contains("export const __devpageUses"), "{system}");
assert_eq!(
get(&format!("{base}/__devpage/system-widgets/Nope.tsx"))
.await
.0,
404
);
let (_, page) = get(&format!("{base}/docs/README.md")).await;
let card_address = page
.split("\"Card\":\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.unwrap()
.to_string();
let served = dir.canonicalize().unwrap();
let docs = Document::load(&served.join("docs"), &served).unwrap();
let known: Vec<String> = crate::widgets::Widgets::of(&docs, &Default::default())
.modules
.into_keys()
.collect();
let address = |file: &str| {
crate::addresses::address(crate::addresses::Owned::Module {
snapshot: &crate::widgets::Modules::default().snapshot(&served, &served.join(file)),
file: &served.join(file),
known: &known,
})
};
assert_eq!(card_address, address("docs/widgets/Card.tsx"));
let response = reqwest::get(format!("{base}{card_address}")).await.unwrap();
assert_eq!(response.status(), 200);
assert_eq!(
response.headers()["cache-control"],
"public, max-age=31536000, immutable"
);
let card = response.text().await.unwrap();
let label = address("docs/widgets/label.ts");
assert!(card.contains(&format!("\"{label}\"")), "{card}");
assert!(card.contains("__devpageUses = [\"Callout\"]"), "{card}");
let response = reqwest::get(format!("{base}{label}")).await.unwrap();
assert_eq!(
response.headers()["cache-control"],
"public, max-age=31536000, immutable"
);
assert!(response.text().await.unwrap().contains("label"));
for stale in [
"/__devpage/widgets/docs/widgets/Card.tsx?v=0123456789abcdef",
"/__devpage/widgets/docs/widgets/Card.tsx",
] {
let response = reqwest::get(format!("{base}{stale}")).await.unwrap();
assert_eq!(response.headers()["cache-control"], "no-cache", "{stale}");
assert_eq!(response.text().await.unwrap(), card, "{stale}");
}
let (status, helper) = get(&format!("{base}/__devpage/widgets/docs/widgets/label")).await;
assert_eq!(status, 200, "an import without an extension finds the .ts");
assert!(helper.contains("label"), "{helper}");
let system = crate::addresses::address(crate::addresses::Owned::SystemWidget {
name: "Tabs",
mount: "",
});
let response = reqwest::get(format!("{base}{system}")).await.unwrap();
assert_eq!(
response.headers()["cache-control"],
"public, max-age=31536000, immutable"
);
let (_, broken) = get(&format!("{base}/__devpage/widgets/docs/widgets/Broken.tsx")).await;
assert!(broken.starts_with("throw new Error("), "{broken}");
let (_, row) = get(&format!("{base}/__devpage/widgets/notes/parts/Row.tsx")).await;
assert!(row.contains("__devpageUses = [\"Pill\"]"), "{row}");
let (status, _) = get(&format!("{base}/__devpage/widgets/../secret.md")).await;
assert_eq!(status, 404);
}
#[tokio::test]
async fn a_page_names_its_widgets_and_carries_their_placeholders() {
let dir = served("widget-page");
std::fs::write(
dir.join("docs/README.md"),
"# Home\n\n<Callout kind=\"tip\">\nSome **bold**\n</Callout>\n\n```md\n<Callout>code</Callout>\n```\n",
)
.unwrap();
let base = serve(&dir, "docs").await;
let (_, page) = get(&format!("{base}/docs/README.md")).await;
assert!(page.contains("<script type=\"importmap\">"), "{page}");
assert!(
page.contains(&format!(
"\"Callout\":\"{}\"",
crate::addresses::address(crate::addresses::Owned::SystemWidget {
name: "Callout",
mount: ""
})
)),
"{page}"
);
assert_eq!(
page.matches("data-widget=\"Callout\"").count(),
1,
"code is left alone: {page}"
);
assert!(page.contains("<strong>bold</strong>"), "{page}");
}
async fn fetch(
url: &str,
headers: &[(&str, &str)],
) -> (u16, reqwest::header::HeaderMap, Vec<u8>) {
let mut request = reqwest::Client::new().get(url);
for (name, value) in headers {
request = request.header(*name, *value);
}
let response = request.send().await.unwrap();
let status = response.status().as_u16();
let headers = response.headers().clone();
(status, headers, response.bytes().await.unwrap().to_vec())
}
#[tokio::test]
async fn every_asset_is_immutable_at_its_content_address_and_answers_at_any_other() {
let dir = served("assets");
let base = serve(&dir, "docs").await;
let (_, page) = get(&format!("{base}/docs/README.md")).await;
for asset in crate::addresses::ASSETS {
let address = crate::addresses::address(crate::addresses::Owned::Asset(asset.name));
if !matches!(
asset.name,
"design.js"
| "design-worker.js"
| "design-stream.js"
| "design-tiles.js"
| "design-canvas.js"
| "design-engine.js"
| "design-engine.wasm"
| "react.js"
) {
assert!(page.contains(&address), "{} not in {page}", asset.name);
}
let (status, headers, body) = fetch(&format!("{base}{address}"), &[]).await;
assert_eq!(status, 200, "{address}");
assert_eq!(
headers["cache-control"],
"public, max-age=31536000, immutable"
);
assert_eq!(headers["content-type"], asset.mime);
assert_eq!(body, asset.identity, "{address}");
let (stem, ext) = asset.name.split_once('.').unwrap();
for old in [
asset.name.to_string(),
format!("{stem}.0123456789abcdef.{ext}"),
] {
let (status, headers, body) = fetch(&format!("{base}/__devpage/{old}"), &[]).await;
assert_eq!(status, 200, "{old}");
assert_eq!(headers["cache-control"], "no-cache", "{old}");
assert!(headers.contains_key("etag"), "{old}");
assert_eq!(body, asset.identity, "{old}");
}
}
assert_eq!(
fetch(&format!("{base}/__devpage/nope.js"), &[]).await.0,
404
);
}
#[tokio::test]
async fn an_asset_is_sent_compressed_as_the_browser_accepts() {
use std::io::Read;
let dir = served("encodings");
let base = serve(&dir, "docs").await;
let react = crate::addresses::address(crate::addresses::Owned::Asset("react.js"));
let identity = crate::addresses::ASSETS
.iter()
.find(|a| a.name == "react.js")
.unwrap()
.identity;
let url = format!("{base}{react}");
let (_, headers, body) =
fetch(&url, &[("accept-encoding", "gzip, deflate, br, zstd")]).await;
assert_eq!(headers["content-encoding"], "br");
assert_eq!(headers["vary"], "Accept-Encoding");
let mut decoded = Vec::new();
brotli::Decompressor::new(&body[..], 4096)
.read_to_end(&mut decoded)
.unwrap();
assert_eq!(decoded, identity);
let br_tag = headers["etag"].clone();
let (_, headers, body) = fetch(&url, &[("accept-encoding", "gzip")]).await;
assert_eq!(headers["content-encoding"], "gzip");
let mut decoded = Vec::new();
flate2::read::GzDecoder::new(&body[..])
.read_to_end(&mut decoded)
.unwrap();
assert_eq!(decoded, identity);
assert_ne!(
headers["etag"], br_tag,
"each encoding is its own representation"
);
let (_, headers, body) = fetch(&url, &[]).await;
assert!(!headers.contains_key("content-encoding"));
assert_eq!(body, identity);
let tag = br_tag.to_str().unwrap();
let (status, headers, body) =
fetch(&url, &[("accept-encoding", "br"), ("if-none-match", tag)]).await;
assert_eq!(status, 304);
assert!(body.is_empty());
assert_eq!(headers["etag"], tag);
}
#[tokio::test]
async fn a_page_and_an_image_answer_304_when_unchanged() {
let dir = served("validators");
std::fs::write(dir.join("docs/logo.png"), b"\x89PNG not really").unwrap();
let base = serve(&dir, "docs").await;
for path in ["/docs/README.md", "/docs/logo.png", "/docs/devpage.yml"] {
let url = format!("{base}{path}");
let (status, headers, _) = fetch(&url, &[]).await;
assert_eq!(status, 200, "{path}");
assert_eq!(headers["cache-control"], "no-cache", "{path}");
let tag = headers["etag"].to_str().unwrap().to_string();
assert!(tag.starts_with('"'), "a strong ETag: {tag}");
let (status, headers, body) = fetch(&url, &[("if-none-match", &tag)]).await;
assert_eq!(status, 304, "{path}");
assert!(body.is_empty(), "{path}");
assert_eq!(headers["etag"], tag.as_str());
let weak = format!("W/{tag}");
assert_eq!(fetch(&url, &[("if-none-match", &weak)]).await.0, 304);
}
let url = format!("{base}/docs/README.md");
let tag = fetch(&url, &[]).await.1["etag"]
.to_str()
.unwrap()
.to_string();
std::fs::write(dir.join("docs/README.md"), "# Home, edited\n").unwrap();
let (status, headers, _) = fetch(&url, &[("if-none-match", &tag)]).await;
assert_eq!(status, 200);
assert_ne!(headers["etag"], tag.as_str());
let events = reqwest::get(format!("{base}/__devpage/events"))
.await
.unwrap();
assert_eq!(events.status(), 200);
assert!(!events.headers().contains_key("etag"));
}
#[tokio::test]
async fn a_pages_event_stream_carries_only_its_documents_changes() {
use crate::changes::{Change, Changes, Scope};
let root = std::env::temp_dir().join(format!("devpage-events-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("a.md"), "# A\n").unwrap();
let document = Arc::new(Document::load(&root.join("a.md"), &root).unwrap());
let changes = Changes::default();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
let router = router(Options {
preview: crate::check::testing::preview(),
..Options::new(document, changes.clone())
});
tokio::spawn(async move { axum::serve(listener, router).await.ok() });
let mut events = reqwest::get(format!("{address}/__devpage/events"))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
changes.publish(&Scope::Mount("/d/other".into()), Change::Refresh);
changes.publish(&Scope::root(), Change::Comments("/a.md".into()));
changes.publish(&Scope::Every, Change::Refresh);
let mut heard = String::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while heard.matches("data:").count() < 2 {
let chunk = tokio::time::timeout_at(deadline, events.chunk())
.await
.expect("two events")
.unwrap()
.expect("an open stream");
heard.push_str(&String::from_utf8_lossy(&chunk));
}
let data: Vec<&str> = heard
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(str::trim)
.collect();
assert_eq!(data, ["comments /a.md", "change"]);
}
}