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]]
name = "rustpad"
version = "0.1.20"
version = "0.1.22"
dependencies = [
"argon2",
"aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rustpad"
version = "0.1.20"
version = "0.1.22"
edition = "2024"
rust-version = "1.94"
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>,
#[serde(default)]
allow_task_updates: bool,
#[serde(default)]
unprotect_page: bool,
}
#[derive(Debug, Deserialize)]
@@ -150,6 +152,7 @@ pub struct NoteInfo {
protected: bool,
note_protected: bool,
allow_public_task_updates: bool,
public_page_unprotected: bool,
created_at: String,
updated_at: String,
can_delete_files: bool,
@@ -445,6 +448,7 @@ pub async fn note_info(
protected: workspace.password_hash.is_some(),
note_protected: note.protected,
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),
updated_at: db::normalize_timestamp(&note.updated_at),
can_delete_files: {
@@ -651,7 +655,7 @@ async fn ensure_private_resource_access(
if verify_resource_access_token(state, kind, slug, token).await? {
return Ok(());
}
Err(ApiError::forbidden("This resource is private."))
Err(ApiError::not_found_workspace())
}
pub async fn authorized_workspace(
@@ -667,7 +671,7 @@ pub async fn authorized_workspace(
let token_level =
combined_token_access_level(state, "workspace", slug, access_token, bearer).await?;
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()
&& !db::verify_workspace_password(&workspace, password)
+47 -2
View File
@@ -19,6 +19,7 @@ pub struct PadInfo {
title: String,
protected: bool,
allow_public_task_updates: bool,
public_page_unprotected: bool,
created_at: String,
updated_at: String,
can_delete_files: bool,
@@ -82,6 +83,7 @@ pub async fn pad_info(
title: pad.title,
protected: pad.password_hash.is_some(),
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),
updated_at: db::normalize_timestamp(&pad.updated_at),
can_delete_files: crate::auth::is_resource_owner(
@@ -181,6 +183,7 @@ pub async fn publish_pad_page(
require_write(level)?;
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_page_unprotected(&state.db, pad.id, payload.unprotect_page).await?;
Ok(Json(PublishResponse {
url: format!("/s/{token}"),
}))
@@ -218,18 +221,58 @@ pub async fn publish_note_page(
require_write(level)?;
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_page_unprotected(&state.db, note.id, payload.unprotect_page).await?;
Ok(Json(PublishResponse {
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(
State(state): State<SharedState>,
headers: HeaderMap,
Path(token): Path<String>,
) -> Result<Json<PublicPageResponse>, ApiError> {
let page = db::find_published_page(&state.db, &token)
.await?
.ok_or_else(ApiError::not_found_note)?;
ensure_public_page_access(&state, &headers, &page).await?;
Ok(Json(PublicPageResponse {
title: page.title,
content: page.content,
@@ -240,12 +283,14 @@ pub async fn public_page(
pub async fn update_public_task(
State(state): State<SharedState>,
headers: HeaderMap,
Path(token): Path<String>,
Json(payload): Json<PublicTaskUpdateRequest>,
) -> Result<Json<PublicPageResponse>, ApiError> {
let current = db::find_published_page(&state.db, &token)
.await?
.ok_or_else(ApiError::not_found_note)?;
ensure_public_page_access(&state, &headers, &current).await?;
if !current.allow_task_updates {
return Err(ApiError::forbidden(
"Task updates are disabled for this page",
@@ -357,13 +402,13 @@ pub(super) async fn authorized_pad(
.ok_or_else(ApiError::not_found_note)?;
let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?;
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()
&& !db::verify_pad_password(&pad, password)
&& token_level == AccessLevel::None
{
return Err(ApiError::unauthorized());
return Err(ApiError::forbidden("Password required or incorrect."));
}
Ok(pad)
}
-1
View File
@@ -50,7 +50,6 @@ pub fn router(
.route("/s/{token}", get(public_page))
.route("/w/{workspace_slug}", get(workspace))
.route("/w/{workspace_slug}/n/{note_slug}", get(note))
.route("/errors/private-workspace", get(private_workspace_error))
.route("/health", get(health))
.route("/robots.txt", get(robots_txt))
.route("/favicon.ico", get(favicon))
+11 -23
View File
@@ -6,18 +6,6 @@ use axum::{
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 {
"ok"
}
@@ -112,7 +100,7 @@ pub(super) async fn workspace(
match db::find_workspace(&state.db, &workspace_slug).await {
Ok(Some(workspace)) => {
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(
&html,
&state.asset_version,
@@ -126,8 +114,8 @@ pub(super) async fn workspace(
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Workspace not found",
"This workspace does not exist or has been deleted.",
"Page not found",
"Check the address or return to the home page.",
"/",
"Home page",
&state.asset_version,
@@ -149,8 +137,8 @@ pub(super) async fn note(
return error_response(
StatusCode::NOT_FOUND,
"404",
"Workspace not found",
"The workspace for this note does not exist or has been deleted.",
"Page not found",
"Check the address or return to the home page.",
"/",
"Home page",
&state.asset_version,
@@ -165,8 +153,8 @@ pub(super) async fn note(
match db::find_note(&state.db, workspace.id, &note_slug).await {
Ok(Some(note)) => {
let html = include_str!("../../static/note.html")
.replace("__NOTE_TITLE__", &escape_html(&note.title))
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
.replace("__NOTE_TITLE__", &escape_html(if workspace.is_private != 0 { "Note" } else { &note.title }))
.replace("__WORKSPACE_TITLE__", &escape_html(if workspace.is_private != 0 { "Workspace" } else { &workspace.title }))
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
assets::render_html(
&html,
@@ -181,10 +169,10 @@ pub(super) async fn note(
Ok(None) => error_response(
StatusCode::NOT_FOUND,
"404",
"Note not found",
"This note does not exist or has been deleted.",
&format!("/w/{workspace_slug}"),
"Back do workspace",
"Page not found",
"Check the address or return to the home page.",
"/",
"Home page",
&state.asset_version,
),
Err(error) => {
-5
View File
@@ -279,11 +279,6 @@ pub async fn identity(
State(state): State<SharedState>,
Json(req): Json<IdentityRequest>,
) -> 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)?;
debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested");
match find_user_by_nickname(&state, &nickname).await? {
+31
View File
@@ -185,6 +185,37 @@ pub async fn set_note_public_task_updates(
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(
pool: &Database,
token: &str,
+8
View File
@@ -132,6 +132,10 @@ pub enum Query {
Q043,
Q044,
Q045,
Q048,
Q049,
Q050,
Q051,
}
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 Q044: Query = Query::Q044;
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)]
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::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::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::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::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::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::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)]
pub struct PresenceUser {
pub name: String,
pub compact_name: 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)]
struct PresenceConnection {
identity: String,
@@ -158,6 +173,7 @@ impl AppState {
PresenceConnection {
identity,
user: PresenceUser {
compact_name: compact_presence_name(&nickname),
name: nickname,
color,
},
+1 -1
View File
@@ -171,7 +171,7 @@ async fn handle_socket(
.unwrap_or(false);
let password_ok = db::verify_workspace_password(&workspace, password.as_deref());
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;
}
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(
&mut socket,
&PadServerMessage::Error {
message: "This note is private".into(),
message: "Note not found".into(),
},
)
.await;
+146 -2
View File
@@ -449,11 +449,18 @@ textarea:focus {
.editor-column,
.preview-column {
display: grid;
grid-template-rows: 30px minmax(0, 1fr);
min-width: 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 {
border-left: 1px solid var(--border);
}
@@ -3993,7 +4000,6 @@ dialog::backdrop {
.authorship-fragment {
border-radius: 2px;
background: color-mix(in srgb, var(--owner) 18%, transparent);
box-shadow: inset 0 -2px color-mix(in srgb, var(--owner) 72%, transparent);
color: transparent;
}
@@ -4554,3 +4560,141 @@ dialog::backdrop {
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">
<h2>My notes and workspaces</h2>
<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>
<div id="resources-list" class="resources-list"></div>
<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;
};
row.querySelector("[data-privacy]")?.addEventListener("click", async () => {
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(); }
catch (e) { resourcesError.textContent = e.message; }
row.querySelector("[data-privacy]")?.addEventListener("click", async event => {
const button = event.currentTarget;
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 () => {
const dialog = document.createElement("dialog");
+4 -4
View File
@@ -27,9 +27,9 @@ export function createPadAdapter() {
method: "POST",
body: JSON.stringify({ kind: "pad", slug, password }),
}),
publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, {
publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, {
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`, {
method: "POST",
@@ -67,9 +67,9 @@ export function createWorkspaceNoteAdapter() {
method: "POST",
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
}),
publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, {
publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, {
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`, {
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 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;
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);
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)");
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";
@@ -61,7 +61,7 @@ export function startNoteEditor(adapter) {
}
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 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" }); } }
@@ -71,48 +71,55 @@ export function startNoteEditor(adapter) {
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 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() {
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const lines = Array.from({ length: lineCount });
const owners = authorshipOwners(authorship);
const showSingleOwner = owners.length === 1;
const showAuthorship = owners.length > 1;
const showAuthorship = owners.length > 0;
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
const full = authorshipMode === "full";
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;
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("");
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
if (showSingleOwner) {
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 {
if (full) {
let previousAuthorSignature = null;
ownerLabels.innerHTML = lines.map((_, i) => {
const authors = authorsByLine[i] || [];
if (!authors.length) return "";
const top = paddingTop + i * lineHeight - editor.scrollTop;
const signature = authors
.map(owner => ownerName(owner))
.sort((a, b) => a.localeCompare(b))
.join("\u0000");
const startsOwnershipBlock = signature !== previousAuthorSignature;
const signature = authors.map(owner => ownerName(owner)).sort((a, b) => a.localeCompare(b)).join("\u0000");
if (signature === previousAuthorSignature) return "";
previousAuthorSignature = signature;
const lineMarker = `<span class="owner-line" style="top:${top}px;--owner:${colorFor(authors[0])}"></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>`;
const badges = authors.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
return `<span class="owner-label-group" style="top:${top}px">${badges}</span>`;
}).join("");
}
} else ownerLabels.replaceChildren();
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
else authorshipLayer.replaceChildren();
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`);
document.body.classList.toggle("compact-editor", compactToggle.checked);
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;
button.classList.toggle("active", 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(); }
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(); }); });
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() {
try {
if (getAuthToken()) {
@@ -247,14 +274,18 @@ export function startNoteEditor(adapter) {
accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key);
await loadNoteInfo();
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);
applyUi({ write: true, replace: true });
updateCurrentUser();
if (info.protected && !accessToken) passwordDialog.showModal();
else { loadFiles(); connect(); }
} 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("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"); } });
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(); });
+8 -2
View File
@@ -9,6 +9,10 @@ import { toast } from "@rustpad/toast";
const token = location.pathname.split("/").filter(Boolean)[1];
const content = document.querySelector("#public-content");
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 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) {
@@ -26,7 +30,7 @@ function scrollToPublicAnchor(hash, behavior = "auto") {
target.scrollIntoView({ behavior, block: "start" });
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 => {
const link = event.target.closest('.markdown-toc a[href^="#"]');
if (!link) return;
@@ -36,7 +40,9 @@ content.addEventListener("click", event => {
history.replaceState(null, "", `${location.pathname}${location.search}${hash}`);
});
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); });
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); } });
initialize();
+17 -3
View File
@@ -92,6 +92,20 @@ function renderNotes(notes = notesCache) {
${deleteButton(note)}
</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() {
try {
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")) {
document.querySelector("#password-error").textContent = e.message;
if (!dialog.open) dialog.showModal();
} else if (e.status === 403) {
location.replace("/errors/private-workspace");
} else if (e.status === 403 || e.status === 404) {
await showSystemNotFound();
} else document.querySelector("#workspace-error").textContent = e.message;
}
}
@@ -119,7 +133,7 @@ async function init() {
document.querySelector("#workspace-url").textContent = location.pathname;
if (info.protected && !accessToken) dialog.showModal(); else openWorkspace();
} 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;
}
}
+3 -3
View File
@@ -31,9 +31,9 @@
</button>
<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 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"
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"
hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div>
</div>
@@ -104,7 +104,7 @@
</div>
<div id="editor-workspace" class="workspace view-split">
<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 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"
+3 -3
View File
@@ -32,10 +32,10 @@
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<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"
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"
class="secondary-button">History</button></div>
</div>
@@ -107,7 +107,7 @@
</div>
<div id="editor-workspace" class="workspace view-split">
<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 id="line-gutter" class="line-gutter" 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>
<article id="public-content" class="markdown-body public-content"></article>
</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>
</body>