This commit is contained in:
Mateusz Gruszczyński
2026-07-28 23:53:37 +02:00
parent bc25e49bd6
commit 09669ff330
28 changed files with 412 additions and 89 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.1.20" version = "0.1.22"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.1.20" version = "0.1.22"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
@@ -0,0 +1 @@
ALTER TABLE published_pages ADD COLUMN unprotected BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1 @@
ALTER TABLE published_pages ADD COLUMN unprotected BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1 @@
ALTER TABLE published_pages ADD COLUMN unprotected INTEGER NOT NULL DEFAULT 0;
+6 -2
View File
@@ -81,6 +81,8 @@ pub struct PublishRequest {
access_token: Option<String>, access_token: Option<String>,
#[serde(default)] #[serde(default)]
allow_task_updates: bool, allow_task_updates: bool,
#[serde(default)]
unprotect_page: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -150,6 +152,7 @@ pub struct NoteInfo {
protected: bool, protected: bool,
note_protected: bool, note_protected: bool,
allow_public_task_updates: bool, allow_public_task_updates: bool,
public_page_unprotected: bool,
created_at: String, created_at: String,
updated_at: String, updated_at: String,
can_delete_files: bool, can_delete_files: bool,
@@ -445,6 +448,7 @@ pub async fn note_info(
protected: workspace.password_hash.is_some(), protected: workspace.password_hash.is_some(),
note_protected: note.protected, note_protected: note.protected,
allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?, allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?,
public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?,
created_at: db::normalize_timestamp(&note.created_at), created_at: db::normalize_timestamp(&note.created_at),
updated_at: db::normalize_timestamp(&note.updated_at), updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files: { can_delete_files: {
@@ -651,7 +655,7 @@ async fn ensure_private_resource_access(
if verify_resource_access_token(state, kind, slug, token).await? { if verify_resource_access_token(state, kind, slug, token).await? {
return Ok(()); return Ok(());
} }
Err(ApiError::forbidden("This resource is private.")) Err(ApiError::not_found_workspace())
} }
pub async fn authorized_workspace( pub async fn authorized_workspace(
@@ -667,7 +671,7 @@ pub async fn authorized_workspace(
let token_level = let token_level =
combined_token_access_level(state, "workspace", slug, access_token, bearer).await?; combined_token_access_level(state, "workspace", slug, access_token, bearer).await?;
if workspace.is_private != 0 && token_level == AccessLevel::None { if workspace.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::forbidden("This workspace is private.")); return Err(ApiError::not_found_workspace());
} }
if workspace.password_hash.is_some() if workspace.password_hash.is_some()
&& !db::verify_workspace_password(&workspace, password) && !db::verify_workspace_password(&workspace, password)
+47 -2
View File
@@ -19,6 +19,7 @@ pub struct PadInfo {
title: String, title: String,
protected: bool, protected: bool,
allow_public_task_updates: bool, allow_public_task_updates: bool,
public_page_unprotected: bool,
created_at: String, created_at: String,
updated_at: String, updated_at: String,
can_delete_files: bool, can_delete_files: bool,
@@ -82,6 +83,7 @@ pub async fn pad_info(
title: pad.title, title: pad.title,
protected: pad.password_hash.is_some(), protected: pad.password_hash.is_some(),
allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?, allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?,
public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?,
created_at: db::normalize_timestamp(&pad.created_at), created_at: db::normalize_timestamp(&pad.created_at),
updated_at: db::normalize_timestamp(&pad.updated_at), updated_at: db::normalize_timestamp(&pad.updated_at),
can_delete_files: crate::auth::is_resource_owner( can_delete_files: crate::auth::is_resource_owner(
@@ -181,6 +183,7 @@ pub async fn publish_pad_page(
require_write(level)?; require_write(level)?;
let token = db::publish_pad(&state.db, pad.id).await?; let token = db::publish_pad(&state.db, pad.id).await?;
db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?; db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?;
db::set_pad_public_page_unprotected(&state.db, pad.id, payload.unprotect_page).await?;
Ok(Json(PublishResponse { Ok(Json(PublishResponse {
url: format!("/s/{token}"), url: format!("/s/{token}"),
})) }))
@@ -218,18 +221,58 @@ pub async fn publish_note_page(
require_write(level)?; require_write(level)?;
let token = db::publish_note(&state.db, note.id).await?; let token = db::publish_note(&state.db, note.id).await?;
db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?; db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?;
db::set_note_public_page_unprotected(&state.db, note.id, payload.unprotect_page).await?;
Ok(Json(PublishResponse { Ok(Json(PublishResponse {
url: format!("/s/{token}"), url: format!("/s/{token}"),
})) }))
} }
fn page_password(headers: &HeaderMap) -> Option<&str> {
headers.get("x-rustpad-page-password").and_then(|value| value.to_str().ok()).map(str::trim).filter(|value| !value.is_empty())
}
async fn ensure_public_page_access(state: &SharedState, headers: &HeaderMap, page: &db::PublishedPage) -> Result<(), ApiError> {
let password = page_password(headers);
let bearer = bearer_token(headers);
if let Some(pad_id) = page.pad_id {
if db::pad_public_page_unprotected(&state.db, pad_id).await? { return Ok(()); }
let sql = match state.db.kind() {
crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1",
_ => "SELECT slug FROM pads WHERE id = ?",
};
let slug: Option<String> = sqlx::query_scalar(sql).bind(pad_id).fetch_optional(state.db.pool()).await?;
let Some(slug) = slug else { return Err(ApiError::not_found_note()); };
let pad = db::find_pad(&state.db, &slug).await?.ok_or_else(ApiError::not_found_note)?;
if db::verify_pad_password(&pad, password) { return Ok(()); }
if verify_resource_access_token(state, "pad", &slug, bearer).await? { return Ok(()); }
return if pad.password_hash.is_some() { Err(ApiError::forbidden("Password required or incorrect.")) } else { Err(ApiError::forbidden("This published page is protected.")) };
}
if let Some(note_id) = page.note_id {
if db::note_public_page_unprotected(&state.db, note_id).await? { return Ok(()); }
let sql = match state.db.kind() {
crate::database::DatabaseKind::Postgres => "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1",
_ => "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = ?",
};
let slug: Option<String> = sqlx::query_scalar(sql).bind(note_id).fetch_optional(state.db.pool()).await?;
let Some(slug) = slug else { return Err(ApiError::not_found_note()); };
let workspace = db::find_workspace(&state.db, &slug).await?.ok_or_else(ApiError::not_found_workspace)?;
if db::verify_workspace_password(&workspace, password) { return Ok(()); }
if verify_resource_access_token(state, "workspace", &slug, bearer).await? { return Ok(()); }
return if workspace.password_hash.is_some() { Err(ApiError::forbidden("Password required or incorrect.")) } else { Err(ApiError::forbidden("This published page is protected.")) };
}
Err(ApiError::not_found_note())
}
pub async fn public_page( pub async fn public_page(
State(state): State<SharedState>, State(state): State<SharedState>,
headers: HeaderMap,
Path(token): Path<String>, Path(token): Path<String>,
) -> Result<Json<PublicPageResponse>, ApiError> { ) -> Result<Json<PublicPageResponse>, ApiError> {
let page = db::find_published_page(&state.db, &token) let page = db::find_published_page(&state.db, &token)
.await? .await?
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
ensure_public_page_access(&state, &headers, &page).await?;
Ok(Json(PublicPageResponse { Ok(Json(PublicPageResponse {
title: page.title, title: page.title,
content: page.content, content: page.content,
@@ -240,12 +283,14 @@ pub async fn public_page(
pub async fn update_public_task( pub async fn update_public_task(
State(state): State<SharedState>, State(state): State<SharedState>,
headers: HeaderMap,
Path(token): Path<String>, Path(token): Path<String>,
Json(payload): Json<PublicTaskUpdateRequest>, Json(payload): Json<PublicTaskUpdateRequest>,
) -> Result<Json<PublicPageResponse>, ApiError> { ) -> Result<Json<PublicPageResponse>, ApiError> {
let current = db::find_published_page(&state.db, &token) let current = db::find_published_page(&state.db, &token)
.await? .await?
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
ensure_public_page_access(&state, &headers, &current).await?;
if !current.allow_task_updates { if !current.allow_task_updates {
return Err(ApiError::forbidden( return Err(ApiError::forbidden(
"Task updates are disabled for this page", "Task updates are disabled for this page",
@@ -357,13 +402,13 @@ pub(super) async fn authorized_pad(
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?; let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?;
if pad.is_private != 0 && token_level == AccessLevel::None { if pad.is_private != 0 && token_level == AccessLevel::None {
return Err(ApiError::forbidden("This note is private.")); return Err(ApiError::not_found_note());
} }
if pad.password_hash.is_some() if pad.password_hash.is_some()
&& !db::verify_pad_password(&pad, password) && !db::verify_pad_password(&pad, password)
&& token_level == AccessLevel::None && token_level == AccessLevel::None
{ {
return Err(ApiError::unauthorized()); return Err(ApiError::forbidden("Password required or incorrect."));
} }
Ok(pad) Ok(pad)
} }
-1
View File
@@ -50,7 +50,6 @@ pub fn router(
.route("/s/{token}", get(public_page)) .route("/s/{token}", get(public_page))
.route("/w/{workspace_slug}", get(workspace)) .route("/w/{workspace_slug}", get(workspace))
.route("/w/{workspace_slug}/n/{note_slug}", get(note)) .route("/w/{workspace_slug}/n/{note_slug}", get(note))
.route("/errors/private-workspace", get(private_workspace_error))
.route("/health", get(health)) .route("/health", get(health))
.route("/robots.txt", get(robots_txt)) .route("/robots.txt", get(robots_txt))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
+11 -23
View File
@@ -6,18 +6,6 @@ use axum::{
use crate::{assets, db, state::SharedState}; use crate::{assets, db, state::SharedState};
pub(super) async fn private_workspace_error(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::FORBIDDEN,
"403",
"Private workspace",
"You do not have permission to access this private workspace. Ask the owner to share it with your account or use a valid share link.",
"/",
"Home page",
&state.asset_version,
)
}
pub(super) async fn health() -> &'static str { pub(super) async fn health() -> &'static str {
"ok" "ok"
} }
@@ -112,7 +100,7 @@ pub(super) async fn workspace(
match db::find_workspace(&state.db, &workspace_slug).await { match db::find_workspace(&state.db, &workspace_slug).await {
Ok(Some(workspace)) => { Ok(Some(workspace)) => {
let html = include_str!("../../static/workspace.html") let html = include_str!("../../static/workspace.html")
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)); .replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title }));
assets::render_html( assets::render_html(
&html, &html,
&state.asset_version, &state.asset_version,
@@ -126,8 +114,8 @@ pub(super) async fn workspace(
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"404", "404",
"Workspace not found", "Page not found",
"This workspace does not exist or has been deleted.", "Check the address or return to the home page.",
"/", "/",
"Home page", "Home page",
&state.asset_version, &state.asset_version,
@@ -149,8 +137,8 @@ pub(super) async fn note(
return error_response( return error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"404", "404",
"Workspace not found", "Page not found",
"The workspace for this note does not exist or has been deleted.", "Check the address or return to the home page.",
"/", "/",
"Home page", "Home page",
&state.asset_version, &state.asset_version,
@@ -165,8 +153,8 @@ pub(super) async fn note(
match db::find_note(&state.db, workspace.id, &note_slug).await { match db::find_note(&state.db, workspace.id, &note_slug).await {
Ok(Some(note)) => { Ok(Some(note)) => {
let html = include_str!("../../static/note.html") let html = include_str!("../../static/note.html")
.replace("__NOTE_TITLE__", &escape_html(&note.title)) .replace("__NOTE_TITLE__", &escape_html(if workspace.is_private != 0 { "Note" } else { &note.title }))
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)) .replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title }))
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug)); .replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
assets::render_html( assets::render_html(
&html, &html,
@@ -181,10 +169,10 @@ pub(super) async fn note(
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"404", "404",
"Note not found", "Page not found",
"This note does not exist or has been deleted.", "Check the address or return to the home page.",
&format!("/w/{workspace_slug}"), "/",
"Back do workspace", "Home page",
&state.asset_version, &state.asset_version,
), ),
Err(error) => { Err(error) => {
-5
View File
@@ -279,11 +279,6 @@ pub async fn identity(
State(state): State<SharedState>, State(state): State<SharedState>,
Json(req): Json<IdentityRequest>, Json(req): Json<IdentityRequest>,
) -> Result<Json<IdentityResponse>, AuthError> { ) -> Result<Json<IdentityResponse>, AuthError> {
if state.ldap.is_some() && req.session_token.is_none() {
return Err(AuthError::unauthorized(
"Log in with your organization account.",
));
}
let nickname = validate_nickname(&req.nickname)?; let nickname = validate_nickname(&req.nickname)?;
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested"); debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
match find_user_by_nickname(&state, &nickname).await? { match find_user_by_nickname(&state, &nickname).await? {
+31
View File
@@ -185,6 +185,37 @@ pub async fn set_note_public_task_updates(
Ok(()) Ok(())
} }
pub async fn pad_public_page_unprotected(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q048)).bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(false));
}
Ok(sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q048)).bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(0) != 0)
}
pub async fn note_public_page_unprotected(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(queries::get(pool.kind(), queries::Q049)).bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(false));
}
Ok(sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q049)).bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(0) != 0)
}
pub async fn set_pad_public_page_unprotected(pool: &Database, pad_id: i64, value: bool) -> Result<(), sqlx::Error> {
publish_pad(pool, pad_id).await?;
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q050));
query = if pool.kind() == DatabaseKind::Postgres { query.bind(value) } else { query.bind(if value { 1i64 } else { 0i64 }) };
query.bind(pad_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn set_note_public_page_unprotected(pool: &Database, note_id: i64, value: bool) -> Result<(), sqlx::Error> {
publish_note(pool, note_id).await?;
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q051));
query = if pool.kind() == DatabaseKind::Postgres { query.bind(value) } else { query.bind(if value { 1i64 } else { 0i64 }) };
query.bind(note_id).execute(pool.pool()).await?;
Ok(())
}
pub async fn update_public_task( pub async fn update_public_task(
pool: &Database, pool: &Database,
token: &str, token: &str,
+8
View File
@@ -132,6 +132,10 @@ pub enum Query {
Q043, Q043,
Q044, Q044,
Q045, Q045,
Q048,
Q049,
Q050,
Q051,
} }
pub fn get(kind: DatabaseKind, query: Query) -> &'static str { pub fn get(kind: DatabaseKind, query: Query) -> &'static str {
@@ -267,6 +271,10 @@ pub const Q042: Query = Query::Q042;
pub const Q043: Query = Query::Q043; pub const Q043: Query = Query::Q043;
pub const Q044: Query = Query::Q044; pub const Q044: Query = Query::Q044;
pub const Q045: Query = Query::Q045; pub const Q045: Query = Query::Q045;
pub const Q048: Query = Query::Q048;
pub const Q049: Query = Query::Q049;
pub const Q050: Query = Query::Q050;
pub const Q051: Query = Query::Q051;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
+5
View File
@@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str {
Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#,
Query::Q044 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#, Query::Q044 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#,
Query::Q045 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#, Query::Q045 => r#"SELECT CAST(CASE WHEN allow_task_updates THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#,
Query::Q048 => r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE pad_id = ?"#,
Query::Q049 => r#"SELECT CAST(CASE WHEN unprotected THEN 1 ELSE 0 END AS SIGNED) FROM published_pages WHERE note_id = ?"#,
Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#,
Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#,
} }
} }
+5
View File
@@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str {
Query::Q043 => r#"UPDATE notes SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"#, Query::Q043 => r#"UPDATE notes SET content = $1, updated_at = (CURRENT_TIMESTAMP::text) WHERE id = $2"#,
Query::Q044 => r#"SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"#, Query::Q044 => r#"SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"#,
Query::Q045 => r#"SELECT allow_task_updates FROM published_pages WHERE note_id = $1"#, Query::Q045 => r#"SELECT allow_task_updates FROM published_pages WHERE note_id = $1"#,
Query::Q048 => r#"SELECT unprotected FROM published_pages WHERE pad_id = $1"#,
Query::Q049 => r#"SELECT unprotected FROM published_pages WHERE note_id = $1"#,
Query::Q050 => r#"UPDATE published_pages SET unprotected = $1 WHERE pad_id = $2"#,
Query::Q051 => r#"UPDATE published_pages SET unprotected = $1 WHERE note_id = $2"#,
} }
} }
+5
View File
@@ -127,5 +127,10 @@ pub fn get(query: Query) -> &'static str {
Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#, Query::Q043 => r#"UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"#,
Query::Q044 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#, Query::Q044 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#,
Query::Q045 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#, Query::Q045 => r#"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#,
Query::Q048 => r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"#,
Query::Q049 => r#"SELECT CASE WHEN unprotected THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"#,
Query::Q050 => r#"UPDATE published_pages SET unprotected = ? WHERE pad_id = ?"#,
Query::Q051 => r#"UPDATE published_pages SET unprotected = ? WHERE note_id = ?"#,
} }
} }
+16
View File
@@ -41,9 +41,24 @@ pub struct NoteUpdate {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct PresenceUser { pub struct PresenceUser {
pub name: String, pub name: String,
pub compact_name: String,
pub color: Option<String>, pub color: Option<String>,
} }
fn compact_presence_name(name: &str) -> String {
let trimmed = name.trim();
let Some((first, rest)) = trimmed.split_once('.') else {
return trimmed.to_string();
};
if first.is_empty() || rest.is_empty() {
return trimmed.to_string();
}
let Some(initial) = first.chars().next() else {
return trimmed.to_string();
};
format!("{initial}.{rest}")
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct PresenceConnection { struct PresenceConnection {
identity: String, identity: String,
@@ -158,6 +173,7 @@ impl AppState {
PresenceConnection { PresenceConnection {
identity, identity,
user: PresenceUser { user: PresenceUser {
compact_name: compact_presence_name(&nickname),
name: nickname, name: nickname,
color, color,
}, },
+1 -1
View File
@@ -171,7 +171,7 @@ async fn handle_socket(
.unwrap_or(false); .unwrap_or(false);
let password_ok = db::verify_workspace_password(&workspace, password.as_deref()); let password_ok = db::verify_workspace_password(&workspace, password.as_deref());
if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok { if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok {
let _ = send_error(&mut socket, "This workspace is private").await; let _ = send_error(&mut socket, "Workspace not found").await;
return; return;
} }
if workspace.password_hash.is_some() if workspace.password_hash.is_some()
+1 -1
View File
@@ -114,7 +114,7 @@ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: Stri
let _ = send_pad( let _ = send_pad(
&mut socket, &mut socket,
&PadServerMessage::Error { &PadServerMessage::Error {
message: "This note is private".into(), message: "Note not found".into(),
}, },
) )
.await; .await;
+146 -2
View File
@@ -449,11 +449,18 @@ textarea:focus {
.editor-column, .editor-column,
.preview-column { .preview-column {
display: grid; display: grid;
grid-template-rows: 30px minmax(0, 1fr);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }
.editor-column {
grid-template-rows: 30px auto minmax(0, 1fr);
}
.preview-column {
grid-template-rows: 30px minmax(0, 1fr);
}
.preview-column { .preview-column {
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
} }
@@ -3993,7 +4000,6 @@ dialog::backdrop {
.authorship-fragment { .authorship-fragment {
border-radius: 2px; border-radius: 2px;
background: color-mix(in srgb, var(--owner) 18%, transparent); background: color-mix(in srgb, var(--owner) 18%, transparent);
box-shadow: inset 0 -2px color-mix(in srgb, var(--owner) 72%, transparent);
color: transparent; color: transparent;
} }
@@ -4554,3 +4560,141 @@ dialog::backdrop {
display: none; display: none;
} }
} }
/* Authorship display modes */
.editor-column-label { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.authorship-mode-control { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: color-mix(in srgb, var(--panel) 88%, transparent); }
.authorship-mode-control button { min-height: 26px; padding: 0 9px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 11px; }
.authorship-mode-control button.active { background: var(--surface-strong, #262b35); color: var(--text); }
.participant-badges { display: flex; flex-wrap: wrap; align-items: center; align-content: center; gap: 6px; min-width: 0; min-height: 0; padding: 7px 12px; border-bottom: 1px solid var(--border); }
.participant-badges[hidden] { display: none; }
.participant-badge { display: inline-flex; flex: 0 0 auto; align-items: center; width: auto; max-width: 100%; min-height: 0; padding: 3px 8px; border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--owner) 18%, transparent); font: 600 11px/1.3 system-ui, sans-serif; }
@media (max-width: 720px) {
.editor-column-label { align-items: flex-start; }
.authorship-mode-control button { padding-inline: 7px; }
.participant-badges { padding: 6px 8px; }
.public-task-toggle { width: 100%; }
}
.public-page-options { display: grid; gap: 4px; align-content: center; }
.public-page-options .public-task-toggle { min-height: 24px; }
@media (max-width: 720px) { .public-page-options { width: 100%; } }
/* Keep the whole editor surface consistent in Simple and Full modes. */
.editor-shell {
background: #0d1015;
}
.editor-shell textarea {
z-index: 3;
background: transparent;
}
.authorship-layer {
z-index: 2;
background: transparent;
}
.owner-labels {
z-index: 4;
}
.line-gutter {
z-index: 5;
}
/* Stable editor canvas in both authorship modes. */
.pad-page .editor-column,
.pad-page .editor-shell {
background: #0d1015;
}
.pad-page .editor-shell {
isolation: isolate;
}
.pad-page .editor-shell::before {
position: absolute;
z-index: 0;
inset: 0;
background: #0d1015;
content: "";
pointer-events: none;
}
.pad-page .editor-shell textarea,
.pad-page .owner-labels {
background: transparent !important;
}
/* The authorship canvas must paint the whole editable area, not only text rows. */
.pad-page .authorship-layer {
width: auto;
height: auto;
min-width: 0;
min-height: 0;
background: #0d1015 !important;
}
.pad-page .line-gutter {
background: #0d1015;
}
.resources-access-rules {
margin: 10px 0 0;
padding: 9px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255, 255, 255, .02);
color: var(--muted);
font-size: .78rem;
line-height: 1.45;
}
/* Full-height editor/gutter separator, independent of the number of text lines. */
.pad-page .editor-shell::after {
position: absolute;
z-index: 6;
top: 0;
bottom: 0;
left: 48px;
width: 1px;
background: var(--border);
content: "";
pointer-events: none;
}
.pad-page .line-gutter {
align-self: stretch;
height: 100%;
min-height: 100%;
border-right: 0;
}
.pad-page.hide-editor-line-numbers .editor-shell::after {
display: none;
}
@media (max-width: 720px) {
.pad-page .editor-shell::after {
left: 38px;
}
}
/* Fill the editor column with the actual editor shell.
The grid version could size the shell to its content in Full authorship mode,
so the gutter separator stopped after the last rendered line. */
.pad-page .editor-column {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.pad-page .editor-column > .column-label,
.pad-page .editor-column > .participant-badges {
flex: 0 0 auto;
}
.pad-page .editor-column > .editor-shell {
flex: 1 1 auto;
width: 100%;
min-height: 0;
}
+1
View File
@@ -118,6 +118,7 @@
<header class="resources-panel__header"> <header class="resources-panel__header">
<h2>My notes and workspaces</h2> <h2>My notes and workspaces</h2>
<p class="dialog-copy">Items created while signed in are assigned to your account.</p> <p class="dialog-copy">Items created while signed in are assigned to your account.</p>
<p class="resources-access-rules"><strong>Access rules:</strong> Public items open from their link; a password adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or valid share links. Unauthorized visitors receive a not-found response.</p>
</header> </header>
<div id="resources-list" class="resources-list"></div> <div id="resources-list" class="resources-list"></div>
<p id="resources-error" class="form-message error" role="alert"></p> <p id="resources-error" class="form-message error" role="alert"></p>
+15 -3
View File
@@ -126,9 +126,21 @@ async function loadResources() {
message.textContent = text; message.textContent = text;
}; };
row.querySelector("[data-privacy]")?.addEventListener("click", async () => { row.querySelector("[data-privacy]")?.addEventListener("click", async event => {
try { await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: !Boolean(item.private) }) }); await loadResources(); } const button = event.currentTarget;
catch (e) { resourcesError.textContent = e.message; } const nextPrivate = !Boolean(item.private);
button.disabled = true;
try {
await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: nextPrivate }) });
item.private = nextPrivate;
button.textContent = nextPrivate ? "Make public" : "Make private";
const meta = row.querySelector(".resource-copy small");
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
} catch (e) {
resourcesError.textContent = e.message;
} finally {
button.disabled = false;
}
}); });
row.querySelector("[data-share]")?.addEventListener("click", async () => { row.querySelector("[data-share]")?.addEventListener("click", async () => {
const dialog = document.createElement("dialog"); const dialog = document.createElement("dialog");
+4 -4
View File
@@ -27,9 +27,9 @@ export function createPadAdapter() {
method: "POST", method: "POST",
body: JSON.stringify({ kind: "pad", slug, password }), body: JSON.stringify({ kind: "pad", slug, password }),
}), }),
publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, { publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, {
method: "POST", method: "POST",
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates }), body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage }),
}), }),
loadHistory: accessToken => api(`${base}/history`, { loadHistory: accessToken => api(`${base}/history`, {
method: "POST", method: "POST",
@@ -67,9 +67,9 @@ export function createWorkspaceNoteAdapter() {
method: "POST", method: "POST",
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }), body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
}), }),
publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, { publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, {
method: "POST", method: "POST",
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates }), body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage }),
}), }),
loadHistory: accessToken => api(`${base}/history`, { loadHistory: accessToken => api(`${base}/history`, {
method: "POST", method: "POST",
+64 -32
View File
@@ -17,9 +17,9 @@ export function startNoteEditor(adapter) {
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog"); const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"); const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread");
let unreadChat = 0; let unreadChat = 0;
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color"); const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken); const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = ""; let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = ["full", "advanced"].includes(localStorage.getItem("rustpad:authorship-mode")) ? "full" : "simple";
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)"); const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
let compactView = uiState.view === "preview" ? "preview" : "edit"; let compactView = uiState.view === "preview" ? "preview" : "edit";
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off"; const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
@@ -61,7 +61,7 @@ export function startNoteEditor(adapter) {
} }
updateCurrentUser(); return info; updateCurrentUser(); return info;
} }
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } } function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; } function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); } function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } } function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
@@ -71,48 +71,55 @@ export function startNoteEditor(adapter) {
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; } function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } } async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } } async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
function renderParticipantBadges(owners) {
if (!participantBadges) return;
const people = new Map();
for (const owner of owners) people.set(ownerName(owner), { name: ownerName(owner), compactName: "", color: colorFor(owner) });
for (const user of presenceUsers) {
const name = user.name || "Guest";
const color = /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(name);
people.set(name, { name, compactName: user.compact_name || name, color });
}
participantBadges.hidden = authorshipMode !== "simple" || people.size < 2;
const compact = people.size > 4;
participantBadges.replaceChildren(...[...people.values()].map(person => {
const badge = document.createElement("span");
badge.className = "participant-badge";
badge.style.setProperty("--owner", person.color);
badge.textContent = compact && person.compactName ? person.compactName : person.name;
badge.title = person.name;
return badge;
}));
}
function renderGutter() { function renderGutter() {
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const lines = Array.from({ length: lineCount }); const lines = Array.from({ length: lineCount });
const owners = authorshipOwners(authorship); const owners = authorshipOwners(authorship);
const showSingleOwner = owners.length === 1; const showAuthorship = owners.length > 0;
const showAuthorship = owners.length > 1;
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : []; const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
const full = authorshipMode === "full";
authorshipLayer.hidden = !showAuthorship; authorshipLayer.hidden = !showAuthorship;
ownerLabels.hidden = !(showSingleOwner || showAuthorship); ownerLabels.hidden = !full || !showAuthorship;
renderParticipantBadges(owners);
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode));
editorWorkspace.dataset.authorshipMode = authorshipMode;
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24; const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`; gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join(""); gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join("");
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`); ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
if (showSingleOwner) { if (full) {
const owner = owners[0];
const top = paddingTop - editor.scrollTop;
const markerHeight = Math.max(lineHeight, lineCount * lineHeight);
const ownerColor = colorFor(owner);
ownerLabels.innerHTML = `<span class="owner-line owner-line--document" style="top:${top}px;height:${markerHeight}px;--owner:${ownerColor}"></span><span class="owner-label-group" style="top:${top}px"><span class="owner-label" style="--owner:${ownerColor}">${escapeHtml(ownerName(owner))}</span></span>`;
} else {
let previousAuthorSignature = null; let previousAuthorSignature = null;
ownerLabels.innerHTML = lines.map((_, i) => { ownerLabels.innerHTML = lines.map((_, i) => {
const authors = authorsByLine[i] || []; const authors = authorsByLine[i] || [];
if (!authors.length) return ""; if (!authors.length) return "";
const top = paddingTop + i * lineHeight - editor.scrollTop; const top = paddingTop + i * lineHeight - editor.scrollTop;
const signature = authors const signature = authors.map(owner => ownerName(owner)).sort((a, b) => a.localeCompare(b)).join("\u0000");
.map(owner => ownerName(owner)) if (signature === previousAuthorSignature) return "";
.sort((a, b) => a.localeCompare(b))
.join("\u0000");
const startsOwnershipBlock = signature !== previousAuthorSignature;
previousAuthorSignature = signature; previousAuthorSignature = signature;
const badges = authors.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
const lineMarker = `<span class="owner-line" style="top:${top}px;--owner:${colorFor(authors[0])}"></span>`; return `<span class="owner-label-group" style="top:${top}px">${badges}</span>`;
if (!startsOwnershipBlock) return lineMarker;
const badges = authors
.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`)
.join("");
return `${lineMarker}<span class="owner-label-group" style="top:${top}px">${badges}</span>`;
}).join(""); }).join("");
} } else ownerLabels.replaceChildren();
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor); if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
else authorshipLayer.replaceChildren(); else authorshipLayer.replaceChildren();
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked); document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
@@ -212,7 +219,12 @@ export function startNoteEditor(adapter) {
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
document.body.classList.toggle("compact-editor", compactToggle.checked); document.body.classList.toggle("compact-editor", compactToggle.checked);
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches); document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
document.querySelectorAll("[data-view]").forEach(button => { document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => {
authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple";
localStorage.setItem("rustpad:authorship-mode", authorshipMode);
renderGutter();
}));
document.querySelectorAll("[data-view]").forEach(button => {
const active = button.dataset.view === view; const active = button.dataset.view === view;
button.classList.toggle("active", active); button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active)); button.setAttribute("aria-pressed", String(active));
@@ -233,6 +245,21 @@ export function startNoteEditor(adapter) {
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); } function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } }); bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function showSystemNotFound() {
try {
const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, {
cache: "no-store",
credentials: "same-origin",
});
const html = await response.text();
document.open();
document.write(html);
document.close();
} catch {
document.body.textContent = "404 Not Found";
}
}
async function initialize() { async function initialize() {
try { try {
if (getAuthToken()) { if (getAuthToken()) {
@@ -247,14 +274,18 @@ export function startNoteEditor(adapter) {
accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key);
await loadNoteInfo(); await loadNoteInfo();
document.title = adapter.title(info); document.title = adapter.title(info);
publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected);
adapter.configureView?.(info); adapter.configureView?.(info);
applyUi({ write: true, replace: true }); applyUi({ write: true, replace: true });
updateCurrentUser(); updateCurrentUser();
if (info.protected && !accessToken) passwordDialog.showModal(); if (info.protected && !accessToken) passwordDialog.showModal();
else { loadFiles(); connect(); } else { loadFiles(); connect(); }
} catch (e) { } catch (e) {
document.body.innerHTML = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`; if (e.status === 403 || e.status === 404) {
await showSystemNotFound();
return;
}
document.body.innerHTML = `<main class="error-page"><div><h1>Page could not be loaded</h1><p>${escapeHtml(e.message)}</p></div></main>`;
} }
} }
@@ -364,7 +395,8 @@ export function startNoteEditor(adapter) {
}); });
window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); }); window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); });
window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true }); window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true });
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await adapter.publish(accessToken, publicTaskUpdates.checked); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await adapter.publish(accessToken, publicTaskUpdates.checked); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } }); const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked);
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { unprotectPublicPage.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await savePublicOptions(); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else { roomDetails.classList.remove("is-mobile-open"); } }); roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else { roomDetails.classList.remove("is-mobile-open"); } });
document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); }); document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); });
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); }); chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
+8 -2
View File
@@ -9,6 +9,10 @@ import { toast } from "@rustpad/toast";
const token = location.pathname.split("/").filter(Boolean)[1]; const token = location.pathname.split("/").filter(Boolean)[1];
const content = document.querySelector("#public-content"); const content = document.querySelector("#public-content");
const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle"); const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
const passwordKey = `rustpad:public-page-password:${token}`;
let pagePassword = sessionStorage.getItem(passwordKey) || "";
function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; }
async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } } async function renderMermaid() { const nodes = content.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } } async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); block.classList.add("hljs"); }); } catch { } }
function lockPublicContent(allowTaskUpdates) { function lockPublicContent(allowTaskUpdates) {
@@ -26,7 +30,7 @@ function scrollToPublicAnchor(hash, behavior = "auto") {
target.scrollIntoView({ behavior, block: "start" }); target.scrollIntoView({ behavior, block: "start" });
return true; return true;
} }
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } } async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
content.addEventListener("click", event => { content.addEventListener("click", event => {
const link = event.target.closest('.markdown-toc a[href^="#"]'); const link = event.target.closest('.markdown-toc a[href^="#"]');
if (!link) return; if (!link) return;
@@ -36,7 +40,9 @@ content.addEventListener("click", event => {
history.replaceState(null, "", `${location.pathname}${location.search}${hash}`); history.replaceState(null, "", `${location.pathname}${location.search}${hash}`);
}); });
window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth")); window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth"));
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } }); content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", headers: pageHeaders(), body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } });
lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); }); lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; sessionStorage.setItem(passwordKey, pagePassword); await initialize(); });
passwordDialog.addEventListener("cancel", event => event.preventDefault());
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } }); document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } });
initialize(); initialize();
+17 -3
View File
@@ -92,6 +92,20 @@ function renderNotes(notes = notesCache) {
${deleteButton(note)} ${deleteButton(note)}
</article>`).join(""); </article>`).join("");
} }
async function showSystemNotFound() {
try {
const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, {
cache: "no-store",
credentials: "same-origin",
});
const html = await response.text();
document.open();
document.write(html);
document.close();
} catch {
document.body.textContent = "404 Not Found";
}
}
async function openWorkspace() { async function openWorkspace() {
try { try {
const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) });
@@ -106,8 +120,8 @@ async function openWorkspace() {
if (info?.protected || e.message.toLowerCase().includes("password")) { if (info?.protected || e.message.toLowerCase().includes("password")) {
document.querySelector("#password-error").textContent = e.message; document.querySelector("#password-error").textContent = e.message;
if (!dialog.open) dialog.showModal(); if (!dialog.open) dialog.showModal();
} else if (e.status === 403) { } else if (e.status === 403 || e.status === 404) {
location.replace("/errors/private-workspace"); await showSystemNotFound();
} else document.querySelector("#workspace-error").textContent = e.message; } else document.querySelector("#workspace-error").textContent = e.message;
} }
} }
@@ -119,7 +133,7 @@ async function init() {
document.querySelector("#workspace-url").textContent = location.pathname; document.querySelector("#workspace-url").textContent = location.pathname;
if (info.protected && !accessToken) dialog.showModal(); else openWorkspace(); if (info.protected && !accessToken) dialog.showModal(); else openWorkspace();
} catch (e) { } catch (e) {
if (e.status === 403) location.replace("/errors/private-workspace"); if (e.status === 403 || e.status === 404) await showSystemNotFound();
else document.querySelector("#workspace-error").textContent = e.message; else document.querySelector("#workspace-error").textContent = e.message;
} }
} }
+3 -3
View File
@@ -31,9 +31,9 @@
</button> </button>
<div id="header-actions" class="header-actions"><button id="copy-link" <div id="header-actions" class="header-actions"><button id="copy-link"
class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Copy link</button><button id="publish-page"
class="secondary-button">Page</button><label class="public-task-toggle" class="secondary-button">Page</button><div class="public-page-options"><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates"
type="checkbox"> Editable tasks on Page</label><button id="files-button" type="checkbox"> Editable tasks on Page</label><label class="public-task-toggle" title="Allow the published page to open without the note password or private access"><input id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div><button id="files-button"
class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button" class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button"
hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div> hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div>
</div> </div>
@@ -104,7 +104,7 @@
</div> </div>
<div id="editor-workspace" class="workspace view-split"> <div id="editor-workspace" class="workspace view-split">
<div class="editor-column"> <div class="editor-column">
<div class="column-label editor-column-label"><span>Editor</span></div> <div class="column-label editor-column-label"><span>Editor</span><div class="authorship-mode-control" role="group" aria-label="Authorship display"><button type="button" data-authorship-mode="simple" class="active">Simple</button><button type="button" data-authorship-mode="full">Full</button></div></div><div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
<div class="editor-shell"> <div class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div> <div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" <div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
+3 -3
View File
@@ -32,10 +32,10 @@
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span> <span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button> </button>
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy <div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
link</button><button id="publish-page" class="secondary-button">Page</button><label link</button><button id="publish-page" class="secondary-button">Page</button><div class="public-page-options"><label
class="public-task-toggle" class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input title="Allow visitors to update task checkboxes on the published page"><input
id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="public-task-updates" type="checkbox"> Editable tasks on Page</label><label class="public-task-toggle" title="Allow the published page to open without the note password or private access"><input id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div><button
id="files-button" class="secondary-button">Files</button><button id="history-button" id="files-button" class="secondary-button">Files</button><button id="history-button"
class="secondary-button">History</button></div> class="secondary-button">History</button></div>
</div> </div>
@@ -107,7 +107,7 @@
</div> </div>
<div id="editor-workspace" class="workspace view-split"> <div id="editor-workspace" class="workspace view-split">
<div class="editor-column"> <div class="editor-column">
<div class="column-label editor-column-label"><span>Editor</span></div> <div class="column-label editor-column-label"><span>Editor</span><div class="authorship-mode-control" role="group" aria-label="Authorship display"><button type="button" data-authorship-mode="simple" class="active">Simple</button><button type="button" data-authorship-mode="full">Full</button></div></div><div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
<div class="editor-shell"> <div class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div> <div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div> <div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
+10
View File
@@ -24,6 +24,16 @@
<p id="public-meta" class="public-meta"></p> <p id="public-meta" class="public-meta"></p>
<article id="public-content" class="markdown-body public-content"></article> <article id="public-content" class="markdown-body public-content"></article>
</main> </main>
<dialog id="public-password-dialog">
<form id="public-password-form" class="dialog-panel">
<h2>Protected page</h2>
<p>Enter the note password or sign in with an account that has access.</p>
<input id="public-password" type="password" autocomplete="current-password" minlength="8" maxlength="128" placeholder="Password">
<p id="public-password-error" class="form-message error"></p>
<button class="primary-button">Open page</button>
<a class="text-button" href="/">Back to home</a>
</form>
</dialog>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
</body> </body>