5e9b5d3cfb
Audio tab and note embeds with waveform, live spectrum (own FFT), seek, speed, loop and volume through ffplay/ffmpeg; code and text files open in an editable source editor with syntax highlighting, a Language chip and a banner to install, pick or ignore a grammar; ico decoding through the largest entry; info card as a clean grid; draw tools: arrows and lines clamped and clipped to the picture with a head, inline text tool, hotkeys; Activity tab with unread count and hotkey; calendar title shows a link icon and a pointing hand; explorer trash button works on press; right panel tab pills shrink so they never cover the collapse button; math: unknown command keeps a parser tooltip; test files grouped into tests/ folders (12-file rule); flaky search-order test waits for the rows. Built by Claude Sonnet and MiniMax M3.1 Flash; the whole tree verified with cargo xtask ci (fmt, clippy -D warnings, 4422 tests, layers, tree, i18n) on a copy of the working tree. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
358 lines
17 KiB
Rust
358 lines
17 KiB
Rust
//! Publish pages and share links over real HTTP: what is served, what stays hidden,
|
|
//! who may publish, and how the anonymous routes are bounded.
|
|
|
|
mod common;
|
|
|
|
use common::Server;
|
|
use notescraft_sync_server::publish;
|
|
use serde_json::{Value, json};
|
|
|
|
/// The header value of a response, or an empty string.
|
|
fn header(headers: &[(String, String)], name: &str) -> String {
|
|
headers.iter().find(|(key, _)| key == name).map(|(_, value)| value.clone()).unwrap_or_default()
|
|
}
|
|
|
|
/// The body of a response as text.
|
|
fn text(body: &[u8]) -> String {
|
|
String::from_utf8_lossy(body).into_owned()
|
|
}
|
|
|
|
/// Writes one note and asserts it landed.
|
|
fn note(server: &Server, vault: &str, token: &str, path: &str, body: &str) {
|
|
let (status, _) = server.put(vault, token, path, body, 0);
|
|
assert_eq!(status, 201, "put {path}");
|
|
}
|
|
|
|
/// Publishes one note and returns the slug and the public path.
|
|
fn publish_note(server: &Server, vault: &str, token: &str, target: &str) -> (String, String) {
|
|
let (status, reply) = server.json("POST", &format!("/v1/vaults/{vault}/publications"), Some(token), &[], Some(&json!({"target": target, "kind": "note"})));
|
|
assert_eq!(status, 200, "{reply}");
|
|
let slug = reply["slug"].as_str().expect("slug").to_owned();
|
|
let url = reply["url_path"].as_str().expect("url").to_owned();
|
|
(slug, url)
|
|
}
|
|
|
|
/// The member ids of a vault, by role.
|
|
fn members_by_role(server: &Server, vault: &str, token: &str) -> Vec<(String, String)> {
|
|
let (_, members) = server.json("GET", &format!("/v1/vaults/{vault}/members"), Some(token), &[], None);
|
|
members
|
|
.as_array()
|
|
.expect("members")
|
|
.iter()
|
|
.map(|row| (row["role"].as_str().expect("role").to_owned(), row["user_id"].as_str().expect("user id").to_owned()))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_published_note_is_a_safe_static_page() {
|
|
let server = Server::start_temp("publish-note");
|
|
let (vault, token, _) = server.owned_vault("note");
|
|
note(&server, &vault, &token, "hello.md", "# Hello\n\n<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n[x](javascript:alert(1))\n");
|
|
let (slug, url) = publish_note(&server, &vault, &token, "hello.md");
|
|
assert_eq!(url, format!("/p/{slug}/hello"));
|
|
|
|
let (status, headers, body) = server.send_full("GET", &url, None, &[], None);
|
|
assert_eq!(status, 200);
|
|
let page = text(&body);
|
|
assert!(page.contains("Hello"), "{page}");
|
|
assert!(!page.contains("<script"), "{page}");
|
|
assert!(!page.contains("<img src=x"), "{page}");
|
|
assert!(page.contains("<script>"), "{page}");
|
|
assert!(page.contains("<img src=x onerror=alert(1)>"), "{page}");
|
|
assert!(page.contains("href=\"#\""), "{page}");
|
|
assert_eq!(header(&headers, "content-type"), "text/html; charset=utf-8");
|
|
assert_eq!(header(&headers, "content-security-policy"), "default-src 'none'; style-src 'unsafe-inline'; img-src https: http:");
|
|
assert_eq!(header(&headers, "x-content-type-options"), "nosniff");
|
|
assert_eq!(header(&headers, "referrer-policy"), "no-referrer");
|
|
assert_eq!(header(&headers, "cache-control"), "public, max-age=60");
|
|
}
|
|
|
|
#[test]
|
|
fn a_published_folder_serves_only_its_markdown_notes() {
|
|
let server = Server::start_temp("publish-folder");
|
|
let (vault, token, _) = server.owned_vault("folder");
|
|
note(&server, &vault, &token, "docs/a.md", "# Note A\n");
|
|
note(&server, &vault, &token, "docs/nested/b.md", "# Note B\n");
|
|
note(&server, &vault, &token, "docs/hidden.md", "---\npublish: false\n---\n# Hidden note\n");
|
|
note(&server, &vault, &token, "docs/picture.png", "not markdown");
|
|
let (status, reply) =
|
|
server.json("POST", &format!("/v1/vaults/{vault}/publications"), Some(&token), &[], Some(&json!({"target": "docs", "kind": "folder"})));
|
|
assert_eq!(status, 200, "{reply}");
|
|
let slug = reply["slug"].as_str().expect("slug").to_owned();
|
|
|
|
let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/docs/a"), None, &[], None);
|
|
assert_eq!(status, 200);
|
|
assert!(text(&body).contains("Note A"));
|
|
|
|
let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/docs/nested/b"), None, &[], None);
|
|
assert_eq!(status, 200);
|
|
assert!(text(&body).contains("Note B"));
|
|
|
|
for hidden in ["docs/hidden", "docs/picture.png", ".obsidian/x.md", "docs/hidden.md"] {
|
|
let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/{hidden}"), None, &[], None);
|
|
assert_eq!(status, 404, "{hidden} must not be served");
|
|
assert!(body.is_empty(), "{hidden} must answer with an empty body");
|
|
}
|
|
|
|
let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/docs"), None, &[], None);
|
|
assert_eq!(status, 200);
|
|
let index = text(&body);
|
|
assert!(index.contains("Note A"), "{index}");
|
|
assert!(index.contains("Note B"), "{index}");
|
|
assert!(!index.contains("Hidden note"), "{index}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_comment_sidecar_is_never_published() {
|
|
let server = Server::start_temp("publish-sidecar");
|
|
let (vault, token, _) = server.owned_vault("sidecar");
|
|
note(&server, &vault, &token, "docs/a.md", "# Note A\n");
|
|
note(&server, &vault, &token, "docs/a.md.comments.json", "{\"version\":1,\"note\":\"docs/a.md\",\"comments\":[]}\n");
|
|
let (status, reply) =
|
|
server.json("POST", &format!("/v1/vaults/{vault}/publications"), Some(&token), &[], Some(&json!({"target": "docs", "kind": "folder"})));
|
|
assert_eq!(status, 200, "{reply}");
|
|
let slug = reply["slug"].as_str().expect("slug").to_owned();
|
|
|
|
// The index of the folder lists the note, never the sidecar.
|
|
let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/docs"), None, &[], None);
|
|
assert_eq!(status, 200);
|
|
let index = text(&body);
|
|
assert!(index.contains("Note A"), "{index}");
|
|
assert!(!index.contains("comments.json"), "{index}");
|
|
|
|
// And the sidecar itself answers 404 with an empty body.
|
|
let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/docs/a.md.comments.json"), None, &[], None);
|
|
assert_eq!(status, 404);
|
|
assert!(body.is_empty(), "the sidecar must answer with an empty body");
|
|
}
|
|
|
|
#[test]
|
|
fn unpublishing_answers_404_at_once() {
|
|
let server = Server::start_temp("publish-unpublish");
|
|
let (vault, token, _) = server.owned_vault("unpublish");
|
|
note(&server, &vault, &token, "gone.md", "# Gone\n");
|
|
let (slug, url) = publish_note(&server, &vault, &token, "gone.md");
|
|
assert_eq!(server.send_full("GET", &url, None, &[], None).0, 200);
|
|
|
|
let (status, reply) = server.json("GET", &format!("/v1/vaults/{vault}/publications"), Some(&token), &[], None);
|
|
assert_eq!(status, 200);
|
|
let id = reply[0]["id"].as_str().expect("id").to_owned();
|
|
let (status, _) = server.send("DELETE", &format!("/v1/vaults/{vault}/publications/{id}"), Some(&token), &[], None);
|
|
assert_eq!(status, 204);
|
|
let (status, _, body) = server.send_full("GET", &url, None, &[], None);
|
|
assert_eq!(status, 404);
|
|
assert_eq!(body, b"".as_slice());
|
|
let (status, _) = server.send("GET", &format!("/p/{slug}/"), None, &[], None);
|
|
assert_eq!(status, 404);
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_owner_publishes_and_only_an_editor_shares() {
|
|
let server = Server::start_temp("publish-roles");
|
|
let (vault, owner, _) = server.owned_vault("roles");
|
|
let (_, editor) = server.paired("roles");
|
|
let (_, viewer) = server.paired("roles");
|
|
let (status, code) = server.invite(&vault, &owner, "editor");
|
|
assert_eq!(status, 200);
|
|
assert_eq!(server.accept(&editor, &code).0, 200);
|
|
let (status, code) = server.invite(&vault, &owner, "viewer");
|
|
assert_eq!(status, 200);
|
|
assert_eq!(server.accept(&viewer, &code).0, 200);
|
|
note(&server, &vault, &owner, "shared.md", "# Shared\n");
|
|
|
|
for role in ["editor", "viewer"] {
|
|
let token = if role == "editor" { editor.as_str() } else { viewer.as_str() };
|
|
let (status, _) =
|
|
server.json("POST", &format!("/v1/vaults/{vault}/publications"), Some(token), &[], Some(&json!({"target": "shared.md", "kind": "note"})));
|
|
assert_eq!(status, 403, "a {role} may not publish");
|
|
}
|
|
let (status, _) = server.json("POST", &format!("/v1/vaults/{vault}/share-links"), Some(&editor), &[], Some(&json!({"path": "shared.md"})));
|
|
assert_eq!(status, 200, "an editor may share");
|
|
let (status, _) = server.json("POST", &format!("/v1/vaults/{vault}/share-links"), Some(&viewer), &[], Some(&json!({"path": "shared.md"})));
|
|
assert_eq!(status, 403, "a viewer may not share");
|
|
assert!(members_by_role(&server, &vault, &owner).iter().any(|(role, _)| role == "owner"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_share_link_serves_one_note_until_it_is_revoked_or_expires() {
|
|
let server = Server::start_temp("publish-share");
|
|
let (vault, token, _) = server.owned_vault("share");
|
|
note(&server, &vault, &token, "shared.md", "# Shared note\n");
|
|
let (status, reply) =
|
|
server.json("POST", &format!("/v1/vaults/{vault}/share-links"), Some(&token), &[], Some(&json!({"path": "shared.md", "expires_in_s": 3600})));
|
|
assert_eq!(status, 200, "{reply}");
|
|
let url = reply["url_path"].as_str().expect("url").to_owned();
|
|
let id = reply["id"].as_str().expect("id").to_owned();
|
|
let token_part = url.strip_prefix("/s/").expect("token path").to_owned();
|
|
assert_eq!(token_part.len(), 32);
|
|
assert!(token_part.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)));
|
|
|
|
let (status, headers, body) = server.send_full("GET", &url, None, &[], None);
|
|
assert_eq!(status, 200);
|
|
assert!(text(&body).contains("Shared note"));
|
|
assert_eq!(header(&headers, "x-robots-tag"), "noindex");
|
|
assert_eq!(header(&headers, "cache-control"), "no-store");
|
|
|
|
// The token never shows up in the listing nor in the audit.
|
|
let (status, rows) = server.json("GET", &format!("/v1/vaults/{vault}/share-links"), Some(&token), &[], None);
|
|
assert_eq!(status, 200);
|
|
let listing = rows.to_string();
|
|
assert!(!listing.contains(&token_part), "{listing}");
|
|
assert!(listing.contains("shared.md"), "{listing}");
|
|
let audit = serde_json::to_string(&server.audit()).expect("audit json");
|
|
assert!(!audit.contains(&token_part), "{audit}");
|
|
assert!(audit.contains("share_link_created"), "{audit}");
|
|
|
|
// The clock is injected: the link is live now and expired later.
|
|
let live = server.state.with_store(|store| publish::lookup_share_link(store, &token_part, 0));
|
|
assert!(live.expect("lookup").is_some());
|
|
let later = u64::try_from(i64::MAX).expect("max clock");
|
|
let expired = server.state.with_store(|store| publish::lookup_share_link(store, &token_part, later));
|
|
assert!(expired.expect("lookup").is_none());
|
|
|
|
// Revoking it answers 404 at once.
|
|
let (status, _) = server.send("DELETE", &format!("/v1/vaults/{vault}/share-links/{id}"), Some(&token), &[], None);
|
|
assert_eq!(status, 204);
|
|
let (status, _, body) = server.send_full("GET", &url, None, &[], None);
|
|
assert_eq!(status, 404);
|
|
assert_eq!(body, b"".as_slice());
|
|
}
|
|
|
|
#[test]
|
|
fn a_slug_is_unique_across_the_server() {
|
|
let server = Server::start_temp("publish-slug");
|
|
let (first, first_token, _) = server.owned_vault("slug");
|
|
let (second, second_token, _) = server.owned_vault("slug");
|
|
let (status, _) = server.json("PATCH", &format!("/v1/vaults/{first}"), Some(&first_token), &[], Some(&json!({"slug": "my-notes"})));
|
|
assert_eq!(status, 204);
|
|
let (status, reply) = server.json("PATCH", &format!("/v1/vaults/{second}"), Some(&second_token), &[], Some(&json!({"slug": "my-notes"})));
|
|
assert_eq!(status, 409, "{reply}");
|
|
let (status, _) = server.json("PATCH", &format!("/v1/vaults/{second}"), Some(&second_token), &[], Some(&json!({"slug": "other-notes"})));
|
|
assert_eq!(status, 204);
|
|
let (status, _) = server.json("PATCH", &format!("/v1/vaults/{second}"), Some(&second_token), &[], Some(&json!({"slug": "not a slug"})));
|
|
assert_eq!(status, 400);
|
|
}
|
|
|
|
#[test]
|
|
fn a_publication_follows_the_slug_of_its_vault() {
|
|
let server = Server::start_temp("publish-slug-page");
|
|
let (vault, token, _) = server.owned_vault("slugpage");
|
|
note(&server, &vault, &token, "a.md", "# A\n");
|
|
let (status, _) = server.json("PATCH", &format!("/v1/vaults/{vault}"), Some(&token), &[], Some(&json!({"slug": "my-vault"})));
|
|
assert_eq!(status, 204);
|
|
let (slug, url) = publish_note(&server, &vault, &token, "a.md");
|
|
assert_eq!(slug, "my-vault");
|
|
assert_eq!(url, "/p/my-vault/a");
|
|
assert_eq!(server.send_full("GET", &url, None, &[], None).0, 200);
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_paths_never_escape_the_vault() {
|
|
let server = Server::start_temp("publish-hostile");
|
|
let (vault, token, _) = server.owned_vault("hostile");
|
|
note(&server, &vault, &token, "a.md", "# A\n");
|
|
let (slug, _) = publish_note(&server, &vault, &token, "a.md");
|
|
let long = "a".repeat(5000);
|
|
for path in ["%2e%2e/secret", "docs/%2e%2e/%2e%2e/etc/passwd", "a%00b", long.as_str()] {
|
|
let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/{path}"), None, &[], None);
|
|
assert!(status == 404 || status == 400, "{path} answered {status}");
|
|
assert!(!text(&body).contains("root:"), "{path} leaked a file");
|
|
}
|
|
let (status, _) =
|
|
server.json("POST", &format!("/v1/vaults/{vault}/publications"), Some(&token), &[], Some(&json!({"target": "../escape.md", "kind": "note"})));
|
|
assert_eq!(status, 400);
|
|
let (status, _) =
|
|
server.json("POST", &format!("/v1/vaults/{vault}/publications"), Some(&token), &[], Some(&json!({"target": ".obsidian/x.md", "kind": "note"})));
|
|
assert_eq!(status, 400);
|
|
}
|
|
|
|
#[test]
|
|
fn the_anonymous_routes_are_rate_limited() {
|
|
let server = Server::start_temp("publish-limit");
|
|
let (vault, token, _) = server.owned_vault("limit");
|
|
note(&server, &vault, &token, "a.md", "# A\n");
|
|
let (_slug, url) = publish_note(&server, &vault, &token, "a.md");
|
|
|
|
// One slug may be read sixty times a minute; the next answer is 429.
|
|
let mut answer = 200;
|
|
for _ in 0..80 {
|
|
let (status, headers, _) = server.send_full("GET", &url, None, &[], None);
|
|
if status == 429 {
|
|
assert!(!header(&headers, "retry-after").is_empty(), "a 429 carries Retry-After");
|
|
answer = status;
|
|
break;
|
|
}
|
|
answer = status;
|
|
}
|
|
assert_eq!(answer, 429);
|
|
|
|
// A scan of unknown slugs is bounded by the global bucket.
|
|
let mut scan = 200;
|
|
for round in 0..700 {
|
|
let (status, headers, _) = server.send_full("GET", &format!("/p/unknown-{round}"), None, &[], None);
|
|
if status == 429 {
|
|
assert_ne!(header(&headers, "retry-after"), "");
|
|
scan = status;
|
|
break;
|
|
}
|
|
scan = status;
|
|
}
|
|
assert_eq!(scan, 429);
|
|
}
|
|
|
|
#[test]
|
|
fn the_publication_listing_names_every_publication() {
|
|
let server = Server::start_temp("publish-list");
|
|
let (vault, token, _) = server.owned_vault("list");
|
|
note(&server, &vault, &token, "a.md", "# A\n");
|
|
let (slug, _) = publish_note(&server, &vault, &token, "a.md");
|
|
let (status, reply) = server.json("GET", &format!("/v1/vaults/{vault}/publications"), Some(&token), &[], None);
|
|
assert_eq!(status, 200);
|
|
let rows: &Vec<Value> = reply.as_array().expect("rows");
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0]["kind"], "note");
|
|
assert_eq!(rows[0]["target"], "a.md");
|
|
assert_eq!(rows[0]["url_path"], format!("/p/{slug}/a"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_root_of_a_publication_lists_its_notes() {
|
|
let server = Server::start_temp("publish-root");
|
|
let (vault, token, _) = server.owned_vault("root");
|
|
note(&server, &vault, &token, "a.md", "# Root A\n");
|
|
let (slug, _) = publish_note(&server, &vault, &token, "a.md");
|
|
for path in [format!("/p/{slug}"), format!("/p/{slug}/")] {
|
|
let (status, _, body) = server.send_full("GET", &path, None, &[], None);
|
|
assert_eq!(status, 200, "{path}");
|
|
assert!(text(&body).contains("Root A"), "{path}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_note_over_the_size_cap_is_not_published() {
|
|
let server = Server::start_temp("publish-large");
|
|
let (vault, token, _) = server.owned_vault("large");
|
|
let big = format!("# Big\n\n{}", "x".repeat(1024 * 1024 + 16));
|
|
note(&server, &vault, &token, "big.md", &big);
|
|
let (status, reply) =
|
|
server.json("POST", &format!("/v1/vaults/{vault}/publications"), Some(&token), &[], Some(&json!({"target": "big.md", "kind": "note"})));
|
|
assert_eq!(status, 413, "{reply}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_share_link_lifetime_stays_inside_its_bounds() {
|
|
let server = Server::start_temp("publish-lifetime");
|
|
let (vault, token, _) = server.owned_vault("lifetime");
|
|
note(&server, &vault, &token, "a.md", "# A\n");
|
|
for seconds in [0, 59, 2_592_001] {
|
|
let (status, _) =
|
|
server.json("POST", &format!("/v1/vaults/{vault}/share-links"), Some(&token), &[], Some(&json!({"path": "a.md", "expires_in_s": seconds})));
|
|
assert_eq!(status, 400, "{seconds} seconds must be refused");
|
|
}
|
|
let (status, reply) =
|
|
server.json("POST", &format!("/v1/vaults/{vault}/share-links"), Some(&token), &[], Some(&json!({"path": "a.md", "expires_in_s": 60})));
|
|
assert_eq!(status, 200, "{reply}");
|
|
let expires = reply["expires_ms"].as_u64().expect("expires");
|
|
assert!(expires > 0, "{reply}");
|
|
}
|