Files
Marcos Paulo 5e9b5d3cfb
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: audio player, code files with highlighting, ico, draw tools fixed, activity panel, calendar-title link, lightbox and shell fixes
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>
2026-10-21 02:31:52 -03:00

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("&lt;script&gt;"), "{page}");
assert!(page.contains("&lt;img src=x onerror=alert(1)&gt;"), "{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}");
}