use askama::Template;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
Form, Json,
};
use serde::{Deserialize, Serialize};
use crate::{
handlers::{AppError, AppState},
helpers::{is_expired, is_password_authed, make_auth_cookie, verify_password},
parser::{extract_frontmatter, parse_document},
rate_limit::ReadRateLimit,
};
#[derive(Template)]
#[template(path = "document.html")]
struct CleanTemplate<'a> {
title: &'a str,
content: &'a str,
slug: &'a str,
base_url: &'a str,
full_view: bool,
body_empty: bool,
expires_at: Option<String>,
description: String,
}
#[derive(Template)]
#[template(path = "dark.html")]
struct DarkTemplate<'a> {
title: &'a str,
content: &'a str,
slug: &'a str,
base_url: &'a str,
body_empty: bool,
expires_at: Option<String>,
description: String,
}
#[derive(Template)]
#[template(path = "paper.html")]
struct PaperTemplate<'a> {
title: &'a str,
content: &'a str,
slug: &'a str,
base_url: &'a str,
body_empty: bool,
expires_at: Option<String>,
description: String,
}
#[derive(Template)]
#[template(path = "minimal.html")]
struct MinimalTemplate<'a> {
title: &'a str,
content: &'a str,
slug: &'a str,
base_url: &'a str,
body_empty: bool,
expires_at: Option<String>,
description: String,
}
#[derive(Template)]
#[template(path = "hearth.html")]
struct HearthTemplate<'a> {
title: &'a str,
content: &'a str,
slug: &'a str,
base_url: &'a str,
full_view: bool,
body_empty: bool,
expires_at: Option<String>,
description: String,
}
#[derive(Template)]
#[template(path = "password.html")]
struct PasswordTemplate<'a> {
slug: &'a str,
base_url: &'a str,
error: Option<&'a str>,
}
#[derive(Template)]
#[template(path = "404.html")]
struct NotFoundTemplate<'a> {
theme: &'a str,
}
#[derive(Template)]
#[template(path = "410.html")]
struct GoneTemplate<'a> {
theme: &'a str,
}
#[derive(Deserialize)]
pub struct SlugQuery {
pub raw: Option<String>,
pub access_token: Option<String>,
pub password: Option<String>,
}
#[derive(Deserialize)]
pub struct AgentQuery {
pub access_token: Option<String>,
pub password: Option<String>,
}
#[derive(Deserialize)]
pub struct UnlockForm {
pub password: String,
}
#[derive(Serialize)]
pub struct DocumentResponse {
pub slug: String,
pub title: String,
pub content: String, pub human_content: String, #[serde(skip_serializing_if = "Option::is_none")]
pub agent_content: Option<String>, pub theme: String,
pub description: Option<String>,
pub created_at: String,
pub expires_at: Option<String>,
}
pub fn accept_prefers_json(headers: &HeaderMap) -> bool {
headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.map(|s| s.contains("application/json"))
.unwrap_or(false)
}
pub fn accept_prefers_markdown(headers: &HeaderMap) -> bool {
headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.map(|s| s.contains("text/markdown"))
.unwrap_or(false)
}
const KNOWN_BOT_AGENTS: &[&str] = &[
"gptbot",
"chatgpt-user",
"claudebot",
"claude-user",
"google-extended",
"googlebot",
"bingbot",
"perplexitybot",
"anthropic",
"google-agent",
];
pub fn is_known_bot(headers: &HeaderMap) -> bool {
let ua = match headers
.get(axum::http::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
{
Some(s) => s.to_lowercase(),
None => return false,
};
KNOWN_BOT_AGENTS.iter().any(|bot| ua.contains(bot))
}
pub fn strip_password_from_content_pub(raw: &str) -> String {
strip_password_from_content(raw)
}
pub(crate) fn strip_password_from_content(raw: &str) -> String {
let lines: Vec<&str> = raw.lines().collect();
if lines.is_empty() || lines[0].trim() != "---" {
return raw.to_string();
}
let close_idx = match lines
.iter()
.enumerate()
.skip(1)
.find(|(_, l)| l.trim() == "---")
{
Some((i, _)) => i,
None => return raw.to_string(),
};
let filtered: Vec<&str> = lines
.iter()
.enumerate()
.filter(|(i, line)| {
if *i >= 1 && *i < close_idx {
let trimmed = line.trim_start();
!trimmed.starts_with("password:")
} else {
true
}
})
.map(|(_, line)| *line)
.collect();
filtered.join("\n")
}
fn build_json_agent_response(doc: &crate::db::DocumentRecord) -> Response {
let safe_content = strip_password_from_content(&doc.raw_content);
let parsed = parse_document(&safe_content, &doc.slug);
let body = DocumentResponse {
slug: doc.slug.clone(),
title: doc.title.clone(),
content: safe_content,
human_content: parsed.human,
agent_content: parsed.agent,
theme: doc.theme.clone(),
description: doc.description.clone(),
created_at: doc.created_at.clone(),
expires_at: doc.expires_at.clone(),
};
(StatusCode::OK, Json(body)).into_response()
}
fn strip_marker_comments(source: &str) -> String {
let mut result: Vec<&str> = Vec::new();
let mut in_instructions = false;
for line in source.lines() {
let t = line.trim();
let tag = if t.starts_with("<!--") && t.ends_with("-->") {
let inner = &t["<!--".len()..t.len() - "-->".len()];
Some(inner.trim())
} else {
None
};
match tag {
Some("@instructions") => {
in_instructions = true;
}
Some("@end-instructions") if in_instructions => {
in_instructions = false;
}
Some("@agent") | Some("@end") if !in_instructions => {
}
_ if in_instructions => {}
_ => {
result.push(line);
}
}
}
result.join("\n")
}
fn render_markdown(source: &str) -> String {
use comrak::{markdown_to_html, Options};
let mut options = Options::default();
options.extension.table = true;
options.extension.strikethrough = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.render.unsafe_ = false;
let rendered = markdown_to_html(source, &options);
ammonia::clean(&rendered)
}
fn render_themed_sync(
title: &str,
content: &str,
slug: &str,
theme: &str,
base_url: &str,
full_view: bool,
expires_at: Option<String>,
) -> Result<Response, AppError> {
let is_dark = theme == "dark";
let highlighted = crate::highlight::apply_syntax_highlighting(content, is_dark);
let body_empty = highlighted.trim().is_empty();
let description = plain_text_excerpt(&highlighted, 150);
let html = match theme {
"dark" => {
let t = DarkTemplate {
title,
content: &highlighted,
slug,
base_url,
body_empty,
expires_at,
description,
};
t.render()
}
"paper" => {
let t = PaperTemplate {
title,
content: &highlighted,
slug,
base_url,
body_empty,
expires_at,
description,
};
t.render()
}
"minimal" => {
let t = MinimalTemplate {
title,
content: &highlighted,
slug,
base_url,
body_empty,
expires_at,
description,
};
t.render()
}
"hearth" => {
let t = HearthTemplate {
title,
content: &highlighted,
slug,
base_url,
full_view,
body_empty,
expires_at,
description,
};
t.render()
}
_ => {
let t = CleanTemplate {
title,
content: &highlighted,
slug,
base_url,
full_view,
body_empty,
expires_at,
description,
};
t.render()
}
};
html.map(|h| Html(h).into_response())
.map_err(|e| AppError::Internal(format!("Template render error: {e}")))
}
fn plain_text_excerpt(html: &str, max_chars: usize) -> String {
let mut result = String::with_capacity(html.len().min(512));
let mut in_tag = false;
let mut last_was_space = true;
for ch in html.chars() {
match ch {
'<' => {
in_tag = true;
}
'>' => {
in_tag = false;
if !last_was_space {
result.push(' ');
last_was_space = true;
}
}
_ if in_tag => {}
'\n' | '\r' | '\t' | ' ' => {
if !last_was_space {
result.push(' ');
last_was_space = true;
}
}
_ => {
result.push(ch);
last_was_space = false;
}
}
}
let trimmed = result.trim().to_string();
if trimmed.chars().count() <= max_chars {
trimmed
} else {
let cut: String = trimmed.chars().take(max_chars).collect();
format!("{cut}...")
}
}
pub fn markdown_response(content: &str) -> Response {
(
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/markdown; charset=utf-8",
)],
content.to_string(),
)
.into_response()
}
pub fn not_found_response() -> Response {
let t = NotFoundTemplate { theme: "hearth" };
match t.render() {
Ok(html) => (
StatusCode::NOT_FOUND,
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "Failed to render 404 template");
StatusCode::NOT_FOUND.into_response()
}
}
}
pub fn gone_response() -> Response {
let t = GoneTemplate { theme: "hearth" };
match t.render() {
Ok(html) => (
StatusCode::GONE,
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
.into_response(),
Err(e) => {
tracing::error!(error = %e, "Failed to render 410 template");
StatusCode::GONE.into_response()
}
}
}
pub async fn get_human(
State(state): State<AppState>,
_rl: ReadRateLimit,
Path(slug): Path<String>,
Query(params): Query<SlugQuery>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let (slug, force_markdown) = if let Some(bare) = slug.strip_suffix(".md") {
(bare.to_string(), true)
} else {
(slug, false)
};
let slug_for_lookup = slug.clone();
let db_lookup = state.db.clone();
let doc = match tokio::task::spawn_blocking(move || db_lookup.get_by_slug(&slug_for_lookup))
.await
.map_err(|e| AppError::Internal(format!("Task failed: {e}")))?
.map_err(AppError::from)?
{
Some(d) => d,
None => return Ok(not_found_response()),
};
if is_expired(&doc) {
return Ok(gone_response());
}
if let Some(stored_hash) = &doc.password {
let query_provided = params
.access_token
.as_deref()
.or(params.password.as_deref());
let query_pw_valid = if let Some(provided) = query_provided {
let provided_owned = provided.to_string();
let hash_owned = stored_hash.clone();
tokio::task::spawn_blocking(move || verify_password(&provided_owned, &hash_owned))
.await
.map_err(|e| AppError::Internal(format!("Auth task failed: {e}")))?
} else {
false
};
if !query_pw_valid && !is_password_authed(&headers, &slug, &state.config.token) {
let template = PasswordTemplate {
slug: &slug,
base_url: state.config.base_url.trim_end_matches('/'),
error: None,
};
return Ok(Html(
template
.render()
.map_err(|e| AppError::Internal(format!("Template error: {e}")))?,
)
.into_response());
}
}
if force_markdown {
return Ok(markdown_response(&strip_password_from_content(
&doc.raw_content,
)));
}
if params.raw.as_deref() == Some("1") {
return Ok(markdown_response(&strip_password_from_content(
&doc.raw_content,
)));
}
if accept_prefers_json(&headers) {
return Ok(build_json_agent_response(&doc));
}
if accept_prefers_markdown(&headers) {
return Ok(markdown_response(&strip_password_from_content(
&doc.raw_content,
)));
}
let accept_explicitly_html = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.map(|s| s.contains("text/html"))
.unwrap_or(false);
if !accept_explicitly_html && is_known_bot(&headers) {
return Ok(build_json_agent_response(&doc));
}
let raw_content = doc.raw_content.clone();
let title = doc.title.clone();
let theme = doc.theme.clone();
let slug_owned = slug.clone();
let expires_at = doc.expires_at.clone();
let base_url = state.config.base_url.trim_end_matches('/').to_string();
let base_url_clone = base_url.clone();
let html_result = tokio::task::spawn_blocking(move || {
let fm_result = extract_frontmatter(&raw_content).unwrap_or_else(|_| {
crate::parser::FrontmatterResult {
meta: None,
body: raw_content.clone(),
}
});
let parse_result = parse_document(&fm_result.body, &slug_owned);
let rendered_html = render_markdown(&parse_result.human);
render_themed_sync(
&title,
&rendered_html,
&slug_owned,
&theme,
&base_url_clone,
false,
expires_at,
)
})
.await
.map_err(|e| AppError::Internal(format!("Render task failed: {e}")))?;
let link_header = format!(
"<{base_url}/api/v1/documents/{slug}>; rel=\"alternate\"; type=\"application/json\"",
);
let html_response = html_result?;
let mut response = html_response.into_response();
response.headers_mut().insert(
axum::http::header::LINK,
axum::http::HeaderValue::from_str(&link_header)
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("")),
);
Ok(response)
}
pub async fn post_unlock(
State(state): State<AppState>,
_rl: ReadRateLimit,
Path(slug): Path<String>,
Form(form): Form<UnlockForm>,
) -> Result<Response, AppError> {
let slug_for_unlock = slug.clone();
let db_unlock = state.db.clone();
let doc = match tokio::task::spawn_blocking(move || db_unlock.get_by_slug(&slug_for_unlock))
.await
.map_err(|e| AppError::Internal(format!("Task failed: {e}")))?
.map_err(AppError::from)?
{
Some(d) => d,
None => return Ok(not_found_response()),
};
if is_expired(&doc) {
return Ok(gone_response());
}
let stored_hash = match &doc.password {
Some(h) => h,
None => {
return Ok(Redirect::to(&format!("/{slug}")).into_response());
}
};
let pw_owned = form.password.clone();
let hash_owned = stored_hash.clone();
let verified = tokio::task::spawn_blocking(move || verify_password(&pw_owned, &hash_owned))
.await
.map_err(|e| AppError::Internal(format!("Task failed: {e}")))?;
if verified {
let cookie_value = make_auth_cookie(&slug, &state.config.token);
let secure_flag = if state.config.base_url.starts_with("https://") {
"; Secure"
} else {
""
};
let cookie_header = format!(
"twofold_auth_{}={}; Path=/{}; HttpOnly; SameSite=Strict; Max-Age=3600{}",
slug, cookie_value, slug, secure_flag
);
Ok((
StatusCode::SEE_OTHER,
[
(axum::http::header::LOCATION, format!("/{slug}")),
(axum::http::header::SET_COOKIE, cookie_header),
],
"",
)
.into_response())
} else {
let template = PasswordTemplate {
slug: &slug,
base_url: state.config.base_url.trim_end_matches('/'),
error: Some("Incorrect password"),
};
Ok(Html(
template
.render()
.map_err(|e| AppError::Internal(format!("Template error: {e}")))?,
)
.into_response())
}
}
pub async fn get_full(
State(state): State<AppState>,
_rl: ReadRateLimit,
Path(slug): Path<String>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let slug_for_full = slug.clone();
let db_full = state.db.clone();
let doc = match tokio::task::spawn_blocking(move || db_full.get_by_slug(&slug_for_full))
.await
.map_err(|e| AppError::Internal(format!("Task failed: {e}")))?
.map_err(AppError::from)?
{
Some(d) => d,
None => return Ok(not_found_response()),
};
if is_expired(&doc) {
return Ok(gone_response());
}
if doc.password.is_some() && !is_password_authed(&headers, &slug, &state.config.token) {
let template = PasswordTemplate {
slug: &slug,
base_url: state.config.base_url.trim_end_matches('/'),
error: None,
};
return Ok(Html(
template
.render()
.map_err(|e| AppError::Internal(format!("Template error: {e}")))?,
)
.into_response());
}
let raw_content = doc.raw_content.clone();
let title = doc.title.clone();
let theme = doc.theme.clone();
let slug_owned = slug.clone();
let expires_at = doc.expires_at.clone();
let base_url = state.config.base_url.trim_end_matches('/').to_string();
let base_url_clone = base_url.clone();
let html_result = tokio::task::spawn_blocking(move || {
let fm_result = extract_frontmatter(&raw_content).unwrap_or_else(|_| {
crate::parser::FrontmatterResult {
meta: None,
body: raw_content.clone(),
}
});
let stripped = strip_marker_comments(&fm_result.body);
let rendered_html = render_markdown(&stripped);
render_themed_sync(
&title,
&rendered_html,
&slug_owned,
&theme,
&base_url_clone,
true,
expires_at,
)
})
.await
.map_err(|e| AppError::Internal(format!("Render task failed: {e}")))?;
let link_header = format!(
"<{base_url}/api/v1/documents/{slug}>; rel=\"alternate\"; type=\"application/json\"",
);
let html_response = html_result?;
let mut response = html_response.into_response();
response.headers_mut().insert(
axum::http::header::LINK,
axum::http::HeaderValue::from_str(&link_header)
.unwrap_or_else(|_| axum::http::HeaderValue::from_static("")),
);
Ok(response)
}
pub async fn get_agent(
State(state): State<AppState>,
_rl: ReadRateLimit,
Path(slug): Path<String>,
Query(params): Query<AgentQuery>,
) -> Result<Response, AppError> {
let slug_for_agent = slug.clone();
let db_agent = state.db.clone();
let doc = tokio::task::spawn_blocking(move || db_agent.get_by_slug(&slug_for_agent))
.await
.map_err(|e| AppError::Internal(format!("Task failed: {e}")))?
.map_err(AppError::from)?
.ok_or(AppError::NotFound)?;
if is_expired(&doc) {
return Err(AppError::Gone);
}
if let Some(stored_hash) = &doc.password {
let provided = params
.access_token
.as_deref()
.or(params.password.as_deref());
match provided {
None => {
return Err(AppError::DocumentPasswordRequired);
}
Some(pw) => {
let pw_owned = pw.to_string();
let hash_owned = stored_hash.clone();
let verified =
tokio::task::spawn_blocking(move || verify_password(&pw_owned, &hash_owned))
.await
.map_err(|e| AppError::Internal(format!("Task failed: {e}")))?;
if !verified {
return Err(AppError::DocumentPasswordInvalid);
}
}
}
}
Ok(markdown_response(&strip_password_from_content(
&doc.raw_content,
)))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
routing::{get, post},
Router,
};
use std::sync::Arc;
use tower::ServiceExt;
use crate::handlers::AppState;
fn make_test_config(token: &str) -> crate::config::ServeConfig {
crate::config::ServeConfig {
token: token.to_string(),
db_path: ":memory:".to_string(),
bind: "127.0.0.1:0".to_string(),
base_url: "http://localhost".to_string(),
default_theme: "clean".to_string(),
max_size: 1_048_576,
webhook_url: None,
webhook_secret: None,
reaper_interval: 3600,
rate_limit_read: 1000,
rate_limit_write: 1000,
rate_limit_window: 60,
registration_limit: 5,
}
}
fn make_test_state(token: &str) -> (AppState, crate::db::Db) {
let db = crate::db::Db::open(":memory:").expect("in-memory db");
let config = make_test_config(token);
let rate_limit = crate::rate_limit::RateLimitStore::new(&config);
let state = AppState {
db: db.clone(),
config: Arc::new(config),
rate_limit: rate_limit.clone(),
};
(state, db)
}
fn test_app_full(token: &str) -> Router {
let db = crate::db::Db::open(":memory:").expect("in-memory db");
let config = make_test_config(token);
let rate_limit = crate::rate_limit::RateLimitStore::new(&config);
let state = AppState {
db,
config: Arc::new(config),
rate_limit: rate_limit.clone(),
};
Router::new()
.route(
"/api/v1/documents",
post(crate::handlers::post_document).get(crate::handlers::list_documents),
)
.route(
"/api/v1/documents/:slug",
get(crate::views::get_agent)
.put(crate::handlers::put_document)
.delete(crate::handlers::delete_document),
)
.route("/:slug/unlock", post(crate::views::post_unlock))
.route("/:slug/full", get(crate::views::get_full))
.route("/:slug", get(crate::views::get_human))
.layer(axum::Extension(rate_limit))
.with_state(state)
}
async fn publish_doc_full(app: Router, token: &str, slug: &str, content: &str) -> String {
let body = format!("---\nslug: {slug}\n---\n{content}");
let req = Request::builder()
.method("POST")
.uri("/api/v1/documents")
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "text/markdown")
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED, "publish failed");
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
json["slug"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn test_human_get_nonexistent_returns_404_with_html() {
let token = "test-token";
let app = test_app_full(token);
let req = Request::builder()
.method("GET")
.uri("/this-slug-does-not-exist")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
content_type.contains("text/html"),
"404 response should be HTML, got: {content_type}"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(
text.contains("Document not found") || text.contains("not found"),
"404 body should contain 'not found' text"
);
assert!(
text.contains("FLINT") || text.contains("flint") || text.contains("twofold"),
"404 body should contain footer branding"
);
assert!(
text.contains("<!DOCTYPE html>"),
"404 body should be valid HTML"
);
}
#[tokio::test]
async fn test_human_get_expired_returns_410_with_html() {
let token = "test-token";
let db = crate::db::Db::open(":memory:").expect("in-memory db");
let config = make_test_config(token);
let expired_doc = crate::db::DocumentRecord {
id: "expired-slug".to_string(),
slug: "expired-slug".to_string(),
title: "Expired Doc".to_string(),
raw_content: "# Expired\nThis document has expired.".to_string(),
theme: "clean".to_string(),
password: None,
description: None,
created_at: "2020-01-01T00:00:00Z".to_string(),
expires_at: Some("2020-06-01T00:00:00Z".to_string()), updated_at: "2020-01-01T00:00:00Z".to_string(),
};
db.insert_document(&expired_doc)
.expect("insert expired doc");
let rate_limit = crate::rate_limit::RateLimitStore::new(&config);
let state = AppState {
db,
config: Arc::new(config),
rate_limit: rate_limit.clone(),
};
let app = Router::new()
.route(
"/api/v1/documents",
post(crate::handlers::post_document).get(crate::handlers::list_documents),
)
.route(
"/api/v1/documents/:slug",
get(crate::views::get_agent)
.put(crate::handlers::put_document)
.delete(crate::handlers::delete_document),
)
.route("/:slug/unlock", post(crate::views::post_unlock))
.route("/:slug/full", get(crate::views::get_full))
.route("/:slug", get(crate::views::get_human))
.layer(axum::Extension(rate_limit))
.with_state(state);
let req = Request::builder()
.method("GET")
.uri("/expired-slug")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::GONE);
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
content_type.contains("text/html"),
"410 response should be HTML, got: {content_type}"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(
text.contains("expired") || text.contains("Expired"),
"410 body should contain 'expired' text"
);
assert!(
text.contains("FLINT") || text.contains("flint") || text.contains("twofold"),
"410 body should contain footer branding"
);
assert!(
text.contains("<!DOCTYPE html>"),
"410 body should be valid HTML"
);
}
#[tokio::test]
async fn test_nonexistent_protected_slug_returns_404_not_password_prompt() {
let token = "test-token";
let app = test_app_full(token);
let req = Request::builder()
.method("GET")
.uri("/nonexistent-protected-slug")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(
!text.contains(r#"type="password""#),
"nonexistent slug should not show password prompt"
);
assert!(
text.contains("not found") || text.contains("Not found"),
"should contain not found message"
);
}
#[tokio::test]
async fn test_content_neg_html_accept_returns_html() {
let token = "test-token";
let app = test_app_full(token);
let slug = publish_doc_full(app.clone(), token, "cn-html", "# Hello").await;
let req = Request::builder()
.method("GET")
.uri(format!("/{slug}"))
.header("Accept", "text/html,application/xhtml+xml,*/*;q=0.9")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.contains("text/html"), "expected HTML, got {ct}");
}
#[tokio::test]
async fn test_content_neg_json_accept_returns_json() {
let token = "test-token";
let app = test_app_full(token);
let slug =
publish_doc_full(app.clone(), token, "cn-json", "# Hello\n\nAgent content.").await;
let req = Request::builder()
.method("GET")
.uri(format!("/{slug}"))
.header("Accept", "application/json")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.contains("application/json"), "expected JSON, got {ct}");
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["slug"].as_str().unwrap(), "cn-json");
assert!(json["content"].as_str().unwrap().contains("Hello"));
}
#[tokio::test]
async fn test_content_neg_markdown_accept_returns_markdown() {
let token = "test-token";
let app = test_app_full(token);
let slug = publish_doc_full(app.clone(), token, "cn-md-accept", "# Markdown test").await;
let req = Request::builder()
.method("GET")
.uri(format!("/{slug}"))
.header("Accept", "text/markdown")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.contains("text/markdown"), "expected markdown, got {ct}");
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = std::str::from_utf8(&bytes).unwrap();
assert!(
body.contains("Markdown test"),
"expected raw markdown in body"
);
}
#[tokio::test]
async fn test_content_neg_bot_ua_returns_json() {
let token = "test-token";
let app = test_app_full(token);
let slug = publish_doc_full(app.clone(), token, "cn-bot", "# Bot content").await;
let req = Request::builder()
.method("GET")
.uri(format!("/{slug}"))
.header("User-Agent", "GPTBot/1.0")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(
ct.contains("application/json"),
"expected JSON for bot UA, got {ct}"
);
}
#[tokio::test]
async fn test_content_neg_html_accept_beats_bot_ua() {
let token = "test-token";
let app = test_app_full(token);
let slug = publish_doc_full(app.clone(), token, "cn-ua-html", "# Dev inspect").await;
let req = Request::builder()
.method("GET")
.uri(format!("/{slug}"))
.header("Accept", "text/html")
.header("User-Agent", "ClaudeBot/1.0")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(
ct.contains("text/html"),
"Accept: text/html should beat bot UA, got {ct}"
);
}
#[tokio::test]
async fn test_slug_md_route_returns_markdown() {
let token = "test-token";
let app = test_app_full(token);
let slug = publish_doc_full(app.clone(), token, "cn-dotmd", "# Dotmd test").await;
let req = Request::builder()
.method("GET")
.uri(format!("/{slug}.md"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(
ct.contains("text/markdown"),
"expected markdown content-type, got {ct}"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let body = std::str::from_utf8(&bytes).unwrap();
assert!(body.contains("Dotmd test"), "expected raw markdown in body");
}
#[tokio::test]
async fn post_unlock_happy_path() {
let token = "test-token";
let password = "correct-horse";
let slug = "locked-doc";
let db = crate::db::Db::open(":memory:").expect("in-memory db");
let hash = crate::helpers::hash_password(password).expect("hash");
let doc = crate::db::DocumentRecord {
id: slug.to_string(),
slug: slug.to_string(),
title: "Locked".to_string(),
raw_content: format!("---\npassword: {hash}\n---\n# Secret"),
theme: "clean".to_string(),
password: Some(hash),
description: None,
created_at: "2024-01-01T00:00:00Z".to_string(),
expires_at: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
db.insert_document(&doc).expect("insert");
let config = make_test_config(token);
let rate_limit = crate::rate_limit::RateLimitStore::new(&config);
let state = AppState {
db,
config: Arc::new(config),
rate_limit: rate_limit.clone(),
};
let app = Router::new()
.route("/:slug/unlock", post(crate::views::post_unlock))
.layer(axum::Extension(rate_limit))
.with_state(state);
let body = format!("password={password}");
let req = Request::builder()
.method("POST")
.uri(format!("/{slug}/unlock"))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::SEE_OTHER,
"correct password should redirect"
);
let set_cookie = resp
.headers()
.get("set-cookie")
.expect("Set-Cookie header should be present");
let cookie_str = set_cookie.to_str().unwrap();
assert!(
cookie_str.contains(&format!("twofold_auth_{}", slug)),
"cookie name should include slug, got: {cookie_str}"
);
}
#[tokio::test]
async fn post_unlock_wrong_password() {
let token = "test-token";
let password = "correct-horse";
let slug = "locked-doc-2";
let db = crate::db::Db::open(":memory:").expect("in-memory db");
let hash = crate::helpers::hash_password(password).expect("hash");
let doc = crate::db::DocumentRecord {
id: slug.to_string(),
slug: slug.to_string(),
title: "Locked".to_string(),
raw_content: format!("---\npassword: {hash}\n---\n# Secret"),
theme: "clean".to_string(),
password: Some(hash),
description: None,
created_at: "2024-01-01T00:00:00Z".to_string(),
expires_at: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
db.insert_document(&doc).expect("insert");
let config = make_test_config(token);
let rate_limit = crate::rate_limit::RateLimitStore::new(&config);
let state = AppState {
db,
config: Arc::new(config),
rate_limit: rate_limit.clone(),
};
let app = Router::new()
.route("/:slug/unlock", post(crate::views::post_unlock))
.layer(axum::Extension(rate_limit))
.with_state(state);
let req = Request::builder()
.method("POST")
.uri(format!("/{slug}/unlock"))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("password=wrong-password"))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::SEE_OTHER,
"wrong password should not redirect"
);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(
text.contains("Incorrect password"),
"should show error message, got: {text}"
);
}
#[test]
fn strip_password_preserves_other_frontmatter() {
let raw = "---\ntitle: My Doc\npassword: secret123\ntheme: clean\n---\n# Body";
let stripped = strip_password_from_content(raw);
assert!(
!stripped.contains("password:"),
"password line should be removed"
);
assert!(
stripped.contains("title: My Doc"),
"title should be preserved"
);
assert!(
stripped.contains("theme: clean"),
"theme should be preserved"
);
assert!(
stripped.contains("# Body"),
"body content should be preserved"
);
assert!(stripped.starts_with("---"), "opening fence should remain");
}
#[tokio::test]
async fn test_agent_get_protected_doc_correct_password_returns_content() {
let token = "test-token";
let (state, _db) = make_test_state(token);
let rate_limit = state.rate_limit.clone();
let app = Router::new()
.route(
"/api/v1/documents",
post(crate::handlers::post_document).get(crate::handlers::list_documents),
)
.route(
"/api/v1/documents/:slug",
get(crate::views::get_agent)
.put(crate::handlers::put_document)
.delete(crate::handlers::delete_document),
)
.layer(axum::Extension(rate_limit))
.with_state(state);
let body = "---\nslug: pw-correct\npassword: hunter2\n---\nSecret content.";
let req = Request::builder()
.method("POST")
.uri("/api/v1/documents")
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "text/markdown")
.body(Body::from(body))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let req = Request::builder()
.method("GET")
.uri("/api/v1/documents/pw-correct?password=hunter2")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("Secret content."));
}
#[tokio::test]
async fn test_agent_get_protected_doc_wrong_password_returns_401() {
let token = "test-token";
let (state, _db) = make_test_state(token);
let rate_limit = state.rate_limit.clone();
let app = Router::new()
.route(
"/api/v1/documents",
post(crate::handlers::post_document).get(crate::handlers::list_documents),
)
.route(
"/api/v1/documents/:slug",
get(crate::views::get_agent)
.put(crate::handlers::put_document)
.delete(crate::handlers::delete_document),
)
.layer(axum::Extension(rate_limit))
.with_state(state);
let body = "---\nslug: pw-wrong\npassword: hunter2\n---\nSecret.";
let req = Request::builder()
.method("POST")
.uri("/api/v1/documents")
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "text/markdown")
.body(Body::from(body))
.unwrap();
app.clone().oneshot(req).await.unwrap();
let req = Request::builder()
.method("GET")
.uri("/api/v1/documents/pw-wrong?password=wrongpass")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["error"].as_str().unwrap(), "Invalid password");
}
#[tokio::test]
async fn test_agent_get_protected_doc_no_password_returns_401() {
let token = "test-token";
let (state, _db) = make_test_state(token);
let rate_limit = state.rate_limit.clone();
let app = Router::new()
.route(
"/api/v1/documents",
post(crate::handlers::post_document).get(crate::handlers::list_documents),
)
.route(
"/api/v1/documents/:slug",
get(crate::views::get_agent)
.put(crate::handlers::put_document)
.delete(crate::handlers::delete_document),
)
.layer(axum::Extension(rate_limit))
.with_state(state);
let body = "---\nslug: pw-none\npassword: hunter2\n---\nSecret.";
let req = Request::builder()
.method("POST")
.uri("/api/v1/documents")
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "text/markdown")
.body(Body::from(body))
.unwrap();
app.clone().oneshot(req).await.unwrap();
let req = Request::builder()
.method("GET")
.uri("/api/v1/documents/pw-none")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["error"].as_str().unwrap(), "Password required");
}
#[tokio::test]
async fn test_agent_get_unprotected_doc_works_without_password() {
let token = "test-token";
let (state, _db) = make_test_state(token);
let rate_limit = state.rate_limit.clone();
let app = Router::new()
.route(
"/api/v1/documents",
post(crate::handlers::post_document).get(crate::handlers::list_documents),
)
.route(
"/api/v1/documents/:slug",
get(crate::views::get_agent)
.put(crate::handlers::put_document)
.delete(crate::handlers::delete_document),
)
.layer(axum::Extension(rate_limit))
.with_state(state);
let body = "---\nslug: no-pw-doc\n---\n# Public\nOpen content.";
let req = Request::builder()
.method("POST")
.uri("/api/v1/documents")
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "text/markdown")
.body(Body::from(body))
.unwrap();
app.clone().oneshot(req).await.unwrap();
let req = Request::builder()
.method("GET")
.uri("/api/v1/documents/no-pw-doc")
.header("Authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("Open content."));
}
}