big features

This commit is contained in:
Mateusz Gruszczyński
2026-07-22 11:03:50 +02:00
parent 0d77624adc
commit 1be2023e36
30 changed files with 1077 additions and 77 deletions
+9
View File
@@ -35,3 +35,12 @@ MYSQL_DATABASE=rustpad
MYSQL_USER=rustpad MYSQL_USER=rustpad
MYSQL_PASSWORD=rustpad MYSQL_PASSWORD=rustpad
MYSQL_ROOT_PASSWORD=rustpad_root MYSQL_ROOT_PASSWORD=rustpad_root
# Optional account password reset via SMTP
REGISTRATION_ENABLED=false
PUBLIC_URL=https://pad.example.com
# SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM="RustPad <no-reply@example.com>"
Generated
+116 -16
View File
@@ -38,6 +38,17 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "async-trait"
version = "0.1.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "atoi" name = "atoi"
version = "2.0.0" version = "2.0.0"
@@ -315,7 +326,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -333,6 +344,22 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "email-encoding"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
dependencies = [
"base64",
"memchr",
]
[[package]]
name = "email_address"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -380,6 +407,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -464,7 +497,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -841,6 +874,33 @@ dependencies = [
"spin", "spin",
] ]
[[package]]
name = "lettre"
version = "0.11.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
dependencies = [
"async-trait",
"base64",
"email-encoding",
"email_address",
"fastrand",
"futures-io",
"futures-util",
"httpdate",
"idna",
"mime",
"nom",
"percent-encoding",
"quoted_printable",
"rustls",
"socket2",
"tokio",
"tokio-rustls",
"url",
"webpki-roots 1.0.8",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
@@ -972,6 +1032,15 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1162,6 +1231,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "quoted_printable"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "5.3.0" version = "5.3.0"
@@ -1302,6 +1377,7 @@ version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [ dependencies = [
"log",
"once_cell", "once_cell",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
@@ -1339,13 +1415,16 @@ dependencies = [
"chrono", "chrono",
"dotenvy", "dotenvy",
"futures-util", "futures-util",
"lettre",
"mime_guess", "mime_guess",
"rand_core 0.6.4", "rand_core 0.6.4",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"slug", "slug",
"sqlx", "sqlx",
"tokio", "tokio",
"tower",
"tower-http", "tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -1396,7 +1475,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -1606,7 +1685,7 @@ dependencies = [
"quote", "quote",
"sqlx-core", "sqlx-core",
"sqlx-macros-core", "sqlx-macros-core",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -1629,7 +1708,7 @@ dependencies = [
"sqlx-mysql", "sqlx-mysql",
"sqlx-postgres", "sqlx-postgres",
"sqlx-sqlite", "sqlx-sqlite",
"syn", "syn 2.0.119",
"tokio", "tokio",
"url", "url",
] ]
@@ -1774,6 +1853,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
@@ -1788,7 +1878,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -1808,7 +1898,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -1869,7 +1959,17 @@ checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
] ]
[[package]] [[package]]
@@ -1982,7 +2082,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -2174,7 +2274,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -2236,7 +2336,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -2247,7 +2347,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -2462,7 +2562,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
"synstructure", "synstructure",
] ]
@@ -2483,7 +2583,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
@@ -2503,7 +2603,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
"synstructure", "synstructure",
] ]
@@ -2543,7 +2643,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.119",
] ]
[[package]] [[package]]
+4 -1
View File
@@ -13,12 +13,15 @@ chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15" dotenvy = "0.15"
futures-util = "0.3" futures-util = "0.3"
mime_guess = "2" mime_guess = "2"
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] }
sha2 = "0.10"
rand_core = { version = "0.6", features = ["getrandom"] } rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
slug = "0.1" slug = "0.1"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] }
tower-http = { version = "0.6", features = ["fs", "trace"] } tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace", "set-header"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+10
View File
@@ -60,3 +60,13 @@ DATABASE_URL=mysql://rustpad:rustpad@mysql:3306/rustpad docker compose up -d rus
``` ```
Migracje są rozdzielone w `migrations/sqlite`, `migrations/postgres` i `migrations/mysql`. Zapytania aplikacji znajdują się centralnie w `src/queries.rs`, a `src/database.rs` odpowiada za wybór sterownika i konfigurację połączenia. Migracje są rozdzielone w `migrations/sqlite`, `migrations/postgres` i `migrations/mysql`. Zapytania aplikacji znajdują się centralnie w `src/queries.rs`, a `src/database.rs` odpowiada za wybór sterownika i konfigurację połączenia.
## Optional user accounts and password reset
Nicknames can still be used anonymously while they remain unregistered. Registering a nickname reserves it and requires a valid login session before it can be used in editor WebSocket connections. Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset e-mails. Reset links expire after 30 minutes and are single-use.
## Database query layout
All runtime SQL statements are centralized in `src/queries.rs`. Backend modules reference named constants, which keeps database-specific debugging and query review in one place.
+7
View File
@@ -14,7 +14,14 @@ services:
FILES_DIR: ${FILES_DIR:-/data/files} FILES_DIR: ${FILES_DIR:-/data/files}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20}
ASSET_VERSION: ${ASSET_VERSION:-dev} ASSET_VERSION: ${ASSET_VERSION:-dev}
REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false}
RUST_LOG: ${RUST_LOG:-rustpad=info,tower_http=info} RUST_LOG: ${RUST_LOG:-rustpad=info,tower_http=info}
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
ports: ports:
- "${RUSTPAD_PORT:-3000}:${APP_PORT:-3000}" - "${RUSTPAD_PORT:-3000}:${APP_PORT:-3000}"
volumes: volumes:
+27
View File
@@ -0,0 +1,27 @@
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
nickname VARCHAR(80) NOT NULL UNIQUE,
nickname_key VARCHAR(80) NOT NULL UNIQUE,
email VARCHAR(320) NOT NULL,
email_key VARCHAR(320) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
updated_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP)
) ENGINE=InnoDB;
CREATE TABLE user_sessions (
token VARCHAR(128) PRIMARY KEY,
user_id BIGINT NOT NULL,
expires_at VARCHAR(64) NOT NULL,
created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT fk_user_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_sessions_user (user_id)
) ENGINE=InnoDB;
CREATE TABLE password_reset_tokens (
token VARCHAR(128) PRIMARY KEY,
user_id BIGINT NOT NULL,
expires_at VARCHAR(64) NOT NULL,
used_at VARCHAR(64),
created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT fk_password_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_password_reset_user (user_id)
) ENGINE=InnoDB;
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
nickname TEXT NOT NULL UNIQUE,
nickname_key TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
email_key TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text)
);
CREATE TABLE user_sessions (
token TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text)
);
CREATE INDEX idx_user_sessions_user ON user_sessions(user_id);
CREATE TABLE password_reset_tokens (
token TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text)
);
CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id);
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nickname TEXT NOT NULL UNIQUE,
nickname_key TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
email_key TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_user_sessions_user ON user_sessions(user_id);
CREATE TABLE password_reset_tokens (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id);
+51 -17
View File
@@ -5,9 +5,10 @@ use axum::{
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
use tower_http::{services::ServeDir, trace::TraceLayer}; use tower::ServiceBuilder;
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
use crate::{api, db, state::SharedState, websocket}; use crate::{api, auth, db, state::SharedState, websocket};
pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize) -> Router { pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize) -> Router {
Router::new() Router::new()
@@ -19,6 +20,13 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
.route("/health", get(health)) .route("/health", get(health))
.route("/f/{token}/{filename}", get(api::download_file)) .route("/f/{token}/{filename}", get(api::download_file))
.route("/files/{directory}/{filename}", get(api::download_legacy_file)) .route("/files/{directory}/{filename}", get(api::download_legacy_file))
.route("/api/auth/identity", post(auth::identity))
.route("/api/auth/register", post(auth::register))
.route("/api/auth/login", post(auth::login))
.route("/api/auth/me", get(auth::me))
.route("/api/auth/logout", post(auth::logout))
.route("/api/auth/password-reset", post(auth::request_reset))
.route("/api/auth/password-reset/confirm", post(auth::confirm_reset))
.route("/api/public/{token}", get(api::public_page)) .route("/api/public/{token}", get(api::public_page))
.route("/api/public/{token}/tasks", post(api::update_public_task)) .route("/api/public/{token}/tasks", post(api::update_public_task))
.route("/api/pads", post(api::create_pad)) .route("/api/pads", post(api::create_pad))
@@ -62,8 +70,17 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
) )
.route("/static", get(static_not_found)) .route("/static", get(static_not_found))
.route("/static/{*path}", get(static_not_found)) .route("/static/{*path}", get(static_not_found))
.nest_service("/assets", ServeDir::new(static_dir)) .nest_service(
"/assets",
ServiceBuilder::new()
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
HeaderValue::from_static("private, must-revalidate"),
))
.service(ServeDir::new(static_dir)),
)
.fallback(not_found) .fallback(not_found)
.method_not_allowed_fallback(method_not_allowed)
.layer(DefaultBodyLimit::max(upload_max_size_bytes.saturating_add(1024 * 1024))) .layer(DefaultBodyLimit::max(upload_max_size_bytes.saturating_add(1024 * 1024)))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state) .with_state(state)
@@ -74,7 +91,7 @@ async fn health() -> &'static str {
} }
async fn home(State(state): State<SharedState>) -> Response { async fn home(State(state): State<SharedState>) -> Response {
versioned_html(include_str!("../static/home.html"), &state.asset_version) versioned_html(include_str!("../static/home.html"), &state.asset_version, state.registration_enabled)
} }
async fn pad( async fn pad(
@@ -85,7 +102,7 @@ async fn pad(
Ok(Some(pad)) => { Ok(Some(pad)) => {
let html = include_str!("../static/pad.html") let html = include_str!("../static/pad.html")
.replace("__PAD_TITLE__", &escape_html(&pad.title)); .replace("__PAD_TITLE__", &escape_html(&pad.title));
versioned_html(&html, &state.asset_version) versioned_html(&html, &state.asset_version, state.registration_enabled)
}, },
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -108,7 +125,7 @@ async fn public_page(
Path(token): Path<String>, Path(token): Path<String>,
) -> Response { ) -> Response {
match db::find_published_page(&state.db, &token).await { match db::find_published_page(&state.db, &token).await {
Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version), Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version, state.registration_enabled),
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"404", "404",
@@ -133,7 +150,7 @@ async fn workspace(
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(&workspace.title));
versioned_html(&html, &state.asset_version) versioned_html(&html, &state.asset_version, state.registration_enabled)
}, },
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -180,7 +197,7 @@ async fn note(
.replace("__NOTE_TITLE__", &escape_html(&note.title)) .replace("__NOTE_TITLE__", &escape_html(&note.title))
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)) .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug)); .replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
versioned_html(&html, &state.asset_version) versioned_html(&html, &state.asset_version, state.registration_enabled)
}, },
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -198,13 +215,28 @@ async fn note(
} }
} }
async fn static_not_found() -> Response { async fn static_not_found(State(state): State<SharedState>) -> Response {
let mut response = (StatusCode::NOT_FOUND, "404").into_response(); error_response(
response.headers_mut().insert( StatusCode::NOT_FOUND,
header::CONTENT_TYPE, "404",
HeaderValue::from_static("text/plain; charset=utf-8"), "File not found",
); "The requested static file does not exist.",
response "/",
"Home page",
&state.asset_version,
)
}
async fn method_not_allowed(State(state): State<SharedState>) -> Response {
error_response(
StatusCode::METHOD_NOT_ALLOWED,
"405",
"Method not allowed",
"This address does not support the requested operation.",
"/",
"Home page",
&state.asset_version,
)
} }
async fn not_found(State(state): State<SharedState>) -> Response { async fn not_found(State(state): State<SharedState>) -> Response {
@@ -253,8 +285,10 @@ fn error_response(
response response
} }
fn versioned_html(template: &str, asset_version: &str) -> Response { fn versioned_html(template: &str, asset_version: &str, registration_enabled: bool) -> Response {
let html = template.replace("__ASSET_VERSION__", asset_version); let html = template
.replace("__ASSET_VERSION__", asset_version)
.replace("__REGISTRATION_ENABLED__", if registration_enabled { "true" } else { "false" });
let mut response = Html(html).into_response(); let mut response = Html(html).into_response();
no_store(&mut response); no_store(&mut response);
response response
+188
View File
@@ -0,0 +1,188 @@
use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2};
use axum::{extract::State, http::{HeaderMap, StatusCode}, Json};
use chrono::{Duration, Utc};
use lettre::{message::Mailbox, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, transport::smtp::authentication::Credentials};
use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use crate::{queries, state::{SharedState, SmtpConfig}};
const MIN_PASSWORD: usize = 8;
const MAX_PASSWORD: usize = 128;
const MAX_NICKNAME: usize = 40;
#[derive(Debug, Clone, FromRow)]
pub struct User { pub id: i64, pub nickname: String, pub email: String, pub password_hash: String }
#[derive(Deserialize)] pub struct IdentityRequest { nickname: String, #[serde(default)] session_token: Option<String> }
#[derive(Deserialize)] pub struct RegisterRequest { nickname: String, email: String, password: String }
#[derive(Deserialize)] pub struct LoginRequest { email: String, password: String }
#[derive(Deserialize)] pub struct ResetRequest { email: String }
#[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String }
#[derive(Serialize)] pub struct SessionResponse { token: String, nickname: String, email: String, expires_at: String }
#[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool }
pub async fn identity(State(state): State<SharedState>, Json(req): Json<IdentityRequest>) -> Result<Json<IdentityResponse>, AuthError> {
let nickname = validate_nickname(&req.nickname)?;
match find_user_by_nickname(&state, &nickname).await? {
None => Ok(Json(IdentityResponse { nickname, registered: false })),
Some(user) => {
let token = req.session_token.as_deref().ok_or_else(|| AuthError::unauthorized("This nickname is registered. Log in to use it."))?;
let current = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired. Log in again."))?;
if current.id != user.id { return Err(AuthError::unauthorized("This nickname belongs to another account.")); }
Ok(Json(IdentityResponse { nickname: user.nickname, registered: true }))
}
}
}
pub async fn register(State(state): State<SharedState>, Json(req): Json<RegisterRequest>) -> Result<(StatusCode, Json<SessionResponse>), AuthError> {
if !state.registration_enabled { return Err(AuthError::forbidden("Registration is disabled.")); }
let nickname = validate_nickname(&req.nickname)?;
let email = validate_email(&req.email)?;
validate_password(&req.password)?;
let nickname_key = normalize(&nickname);
let email_key = normalize(&email);
if find_user_by_nickname(&state, &nickname).await?.is_some() { return Err(AuthError::conflict("This nickname is already registered.")); }
if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict("This e-mail address is already registered.")); }
let hash = hash_password(&req.password)?;
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER))
.bind(&nickname).bind(nickname_key).bind(&email).bind(email_key).bind(hash).execute(state.db.pool()).await
.map_err(AuthError::database)?;
let user = find_user_by_nickname(&state, &nickname).await?.ok_or_else(|| AuthError::internal("Failed to create the account."))?;
Ok((StatusCode::CREATED, Json(create_session(&state, &user).await?)))
}
pub async fn login(State(state): State<SharedState>, Json(req): Json<LoginRequest>) -> Result<Json<SessionResponse>, AuthError> {
let email = validate_email(&req.email)?;
let user = find_user_by_email(&state, &email).await?.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?;
if !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized("Invalid e-mail address or password.")); }
Ok(Json(create_session(&state, &user).await?))
}
pub async fn me(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<SessionResponse>, AuthError> {
let token = bearer(&headers).ok_or_else(|| AuthError::unauthorized("Not logged in."))?;
let user = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired."))?;
let expires_at: String = sqlx::query_scalar(queries::get(state.db.kind(), queries::AUTH_SESSION_EXPIRES_AT))
.bind(token).fetch_one(state.db.pool()).await.map_err(AuthError::database)?;
Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at }))
}
pub async fn logout(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<serde_json::Value>, AuthError> {
if let Some(token) = bearer(&headers) { sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN)).bind(token).execute(state.db.pool()).await.map_err(AuthError::database)?; }
Ok(Json(serde_json::json!({"ok": true})))
}
pub async fn request_reset(State(state): State<SharedState>, Json(req): Json<ResetRequest>) -> Result<Json<serde_json::Value>, AuthError> {
let email = validate_email(&req.email)?;
let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("Password reset is not configured on this server."))?;
if let Some(user) = find_user_by_email(&state, &email).await? {
let token = random_token();
let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339();
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_RESET_TOKENS_BY_USER)).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_RESET_TOKEN))
.bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).await.map_err(AuthError::database)?;
send_reset(smtp, &user, &token).await?;
}
Ok(Json(serde_json::json!({"ok": true, "message": "If the account exists, a reset link has been sent."})))
}
pub async fn confirm_reset(State(state): State<SharedState>, Json(req): Json<ResetConfirmRequest>) -> Result<Json<serde_json::Value>, AuthError> {
validate_password(&req.password)?;
let now_time = Utc::now();
let now = now_time.to_rfc3339();
let token_hash = hash_token(req.token.trim());
let token_row: Option<(i64, String, Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_FIND_RESET_TOKEN))
.bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
let (user_id, expires_at, used_at) = token_row.ok_or_else(|| AuthError::bad_request("The reset link is invalid or has expired."))?;
let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at)
.map_err(|_| AuthError::bad_request("The reset link is invalid or has expired."))?
.with_timezone(&Utc);
if used_at.is_some() || expires_at <= now_time {
return Err(AuthError::bad_request("The reset link is invalid or has expired."));
}
let password_hash = hash_password(&req.password)?;
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
let updated = sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD))
.bind(password_hash).bind(&now).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
if updated.rows_affected() != 1 { return Err(AuthError::internal("The account could not be updated.")); }
sqlx::query(queries::get(state.db.kind(), queries::AUTH_MARK_RESET_TOKEN_USED))
.bind(&now).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?;
sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSIONS_BY_USER))
.bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?;
tx.commit().await.map_err(AuthError::database)?;
Ok(Json(serde_json::json!({"ok": true})))
}
pub async fn user_from_token(state: &SharedState, token: &str) -> Result<Option<User>, AuthError> {
let now = Utc::now().to_rfc3339();
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION))
.bind(token).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)
}
pub async fn authorize_nickname(state: &SharedState, nickname: Option<String>, token: Option<String>) -> Result<Option<String>, String> {
let Some(nickname) = nickname else { return Ok(None); };
let nickname = validate_nickname(&nickname).map_err(|e| e.message)?;
let registered = find_user_by_nickname(state, &nickname).await.map_err(|_| "Database error".to_string())?;
match registered {
None => Ok(Some(nickname)),
Some(owner) => {
let Some(token) = token else { return Err("This nickname is registered. Log in to use it.".into()); };
let current = user_from_token(state, &token).await.map_err(|_| "Database error".to_string())?;
match current { Some(user) if user.id == owner.id => Ok(Some(owner.nickname)), _ => Err("This nickname belongs to another account or the session expired.".into()) }
}
}
}
async fn create_session(state: &SharedState, user: &User) -> Result<SessionResponse, AuthError> {
let token = random_token();
let expires_at = (Utc::now() + Duration::days(30)).to_rfc3339();
sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_SESSION))
.bind(&token).bind(user.id).bind(&expires_at).execute(state.db.pool()).await.map_err(AuthError::database)?;
Ok(SessionResponse { token, nickname: user.nickname.clone(), email: user.email.clone(), expires_at })
}
async fn find_user_by_nickname(state: &SharedState, nickname: &str) -> Result<Option<User>, AuthError> {
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_NICKNAME))
.bind(normalize(nickname)).fetch_optional(state.db.pool()).await.map_err(AuthError::database)
}
async fn find_user_by_email(state: &SharedState, email: &str) -> Result<Option<User>, AuthError> {
sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL))
.bind(normalize(email)).fetch_optional(state.db.pool()).await.map_err(AuthError::database)
}
fn validate_nickname(v: &str) -> Result<String, AuthError> { let v=v.trim(); if v.is_empty() || v.chars().count()>MAX_NICKNAME { return Err(AuthError::bad_request("Nickname must contain 1 to 40 characters.")); } if v.chars().any(|c| c.is_control()) { return Err(AuthError::bad_request("Nickname contains invalid characters.")); } Ok(v.into()) }
fn validate_email(v: &str) -> Result<String, AuthError> { let v=v.trim(); if v.len()>320 || !v.contains('@') || v.starts_with('@') || v.ends_with('@') { return Err(AuthError::bad_request("Enter a valid e-mail address.")); } Ok(v.into()) }
fn validate_password(v: &str) -> Result<(), AuthError> { if v.len()<MIN_PASSWORD || v.len()>MAX_PASSWORD { Err(AuthError::bad_request("Password must contain 8 to 128 characters.")) } else { Ok(()) } }
fn normalize(v: &str)->String { v.trim().to_lowercase() }
fn hash_password(v:&str)->Result<String,AuthError>{let salt=SaltString::generate(&mut OsRng);Argon2::default().hash_password(v.as_bytes(),&salt).map(|h|h.to_string()).map_err(|_|AuthError::internal("Failed to secure the password."))}
fn verify_password(hash:&str,v:&str)->bool{PasswordHash::new(hash).ok().and_then(|h|Argon2::default().verify_password(v.as_bytes(),&h).ok()).is_some()}
fn random_token()->String{let mut bytes=[0u8;32];let mut rng=OsRng;rng.fill_bytes(&mut bytes);bytes.iter().map(|b|format!("{b:02x}")).collect()}
fn hash_token(v:&str)->String{format!("{:x}",Sha256::digest(v.as_bytes()))}
fn bearer(headers:&HeaderMap)->Option<&str>{headers.get("authorization")?.to_str().ok()?.strip_prefix("Bearer ")}
async fn send_reset(smtp:&SmtpConfig,user:&User,token:&str)->Result<(),AuthError>{
let url=format!("{}/?reset_token={}",smtp.public_url.trim_end_matches('/'),token);
let message=Message::builder().from(smtp.from.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::<Mailbox>().map_err(|_|AuthError::internal("Invalid recipient address."))?).subject("RustPad password reset").body(format!("Hello {},\n\nUse this link within 30 minutes to set a new password:\n{}\n\nIf you did not request this, ignore this message.",user.nickname,url)).map_err(|_|AuthError::internal("Failed to build reset e-mail."))?;
// Port 465 uses implicit TLS. Standard submission ports (usually 587)
// require STARTTLS; using implicit TLS there causes an immediate SMTP failure.
let mut builder = if smtp.port == 465 {
AsyncSmtpTransport::<Tokio1Executor>::relay(&smtp.host)
} else {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
}
.map_err(|error| {
tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "invalid SMTP configuration");
AuthError::internal("Invalid SMTP configuration.")
})?
.port(smtp.port);
if !smtp.username.is_empty() { builder=builder.credentials(Credentials::new(smtp.username.clone(),smtp.password.clone())); }
let mailer=builder.build();
mailer.send(message).await.map_err(|error| {
tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "password reset e-mail failed");
AuthError::service_unavailable("The reset e-mail could not be sent. Check the SMTP configuration.")
})?;
Ok(())
}
pub struct AuthError { status: StatusCode, pub message: String }
impl AuthError { fn bad_request(m:&str)->Self{Self{status:StatusCode::BAD_REQUEST,message:m.into()}} fn unauthorized(m:&str)->Self{Self{status:StatusCode::UNAUTHORIZED,message:m.into()}} fn forbidden(m:&str)->Self{Self{status:StatusCode::FORBIDDEN,message:m.into()}} fn conflict(m:&str)->Self{Self{status:StatusCode::CONFLICT,message:m.into()}} fn internal(m:&str)->Self{Self{status:StatusCode::INTERNAL_SERVER_ERROR,message:m.into()}} fn service_unavailable(m:&str)->Self{Self{status:StatusCode::SERVICE_UNAVAILABLE,message:m.into()}} fn database(e:sqlx::Error)->Self{tracing::error!(error=%e,"authentication database error");Self::internal("Database error.")} }
impl axum::response::IntoResponse for AuthError { fn into_response(self)->axum::response::Response{(self.status,Json(serde_json::json!({"error":self.message}))).into_response()} }
+28 -1
View File
@@ -10,6 +10,8 @@ pub struct Config {
pub files_dir: String, pub files_dir: String,
pub upload_max_size_bytes: usize, pub upload_max_size_bytes: usize,
pub asset_version: String, pub asset_version: String,
pub smtp: Option<crate::state::SmtpConfig>,
pub registration_enabled: bool,
} }
impl Config { impl Config {
@@ -26,6 +28,18 @@ impl Config {
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into()); return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
} }
let smtp_host = std::env::var("SMTP_HOST").ok().filter(|v| !v.trim().is_empty());
let smtp = if let Some(host) = smtp_host {
Some(crate::state::SmtpConfig {
host,
port: env_var("SMTP_PORT", "587").parse()?,
username: std::env::var("SMTP_USERNAME").unwrap_or_default(),
password: std::env::var("SMTP_PASSWORD").unwrap_or_default(),
from: std::env::var("SMTP_FROM").map_err(|_| "SMTP_FROM is required when SMTP_HOST is set")?,
public_url: std::env::var("PUBLIC_URL").map_err(|_| "PUBLIC_URL is required when SMTP_HOST is set")?,
})
} else { None };
Ok(Self { Ok(Self {
host, host,
port, port,
@@ -39,7 +53,9 @@ impl Config {
upload_max_size_bytes: upload_max_size_mb upload_max_size_bytes: upload_max_size_mb
.checked_mul(1024 * 1024) .checked_mul(1024 * 1024)
.ok_or("UPLOAD_MAX_SIZE_MB is too large")?, .ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
asset_version: env!("CARGO_PKG_VERSION").to_owned(), asset_version: env_var("ASSET_VERSION", env!("CARGO_PKG_VERSION")),
smtp,
registration_enabled: env_bool("REGISTRATION_ENABLED", false)?,
}) })
} }
} }
@@ -47,3 +63,14 @@ impl Config {
fn env_var(name: &str, default: &str) -> String { fn env_var(name: &str, default: &str) -> String {
env::var(name).unwrap_or_else(|_| default.to_owned()) env::var(name).unwrap_or_else(|_| default.to_owned())
} }
fn env_bool(name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
match env::var(name) {
Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(format!("{name} must be true or false").into()),
},
Err(_) => Ok(default),
}
}
+4 -3
View File
@@ -1,3 +1,4 @@
use crate::queries;
use sqlx::{any::AnyPoolOptions, AnyPool}; use sqlx::{any::AnyPoolOptions, AnyPool};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -22,9 +23,9 @@ impl Database {
.connect(url) .connect(url)
.await?; .await?;
if kind == DatabaseKind::Sqlite { if kind == DatabaseKind::Sqlite {
sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await?; sqlx::query(queries::SQLITE_FOREIGN_KEYS_ON).execute(&pool).await?;
sqlx::query("PRAGMA journal_mode = WAL").execute(&pool).await?; sqlx::query(queries::SQLITE_JOURNAL_WAL).execute(&pool).await?;
sqlx::query("PRAGMA busy_timeout = 5000").execute(&pool).await?; sqlx::query(queries::SQLITE_BUSY_TIMEOUT).execute(&pool).await?;
} }
Ok(Self { pool, kind }) Ok(Self { pool, kind })
} }
+7 -6
View File
@@ -11,11 +11,11 @@ use sqlx::{Any, Transaction};
async fn inserted_id(kind: DatabaseKind, tx: &mut Transaction<'_, Any>, table: &str) -> Result<i64, sqlx::Error> { async fn inserted_id(kind: DatabaseKind, tx: &mut Transaction<'_, Any>, table: &str) -> Result<i64, sqlx::Error> {
let query = match kind { let query = match kind {
DatabaseKind::Sqlite => "SELECT last_insert_rowid()", DatabaseKind::Sqlite => queries::SQLITE_LAST_INSERT_ID,
DatabaseKind::MySql => "SELECT LAST_INSERT_ID()", DatabaseKind::MySql => queries::MYSQL_LAST_INSERT_ID,
DatabaseKind::Postgres => match table { DatabaseKind::Postgres => match table {
"note_revisions" => "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))", "note_revisions" => queries::POSTGRES_NOTE_REVISION_LAST_INSERT_ID,
"revisions" => "SELECT currval(pg_get_serial_sequence('revisions', 'id'))", "revisions" => queries::POSTGRES_PAD_REVISION_LAST_INSERT_ID,
_ => unreachable!("unsupported identity table"), _ => unreachable!("unsupported identity table"),
}, },
}; };
@@ -36,7 +36,8 @@ pub struct Workspace {
pub struct Note { pub struct Note {
pub id: i64, pub id: i64,
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub workspace_id: i64, #[sqlx(rename = "workspace_id")]
pub _workspace_id: i64,
pub slug: String, pub slug: String,
pub title: String, pub title: String,
#[serde(skip_serializing)] #[serde(skip_serializing)]
@@ -66,7 +67,7 @@ impl From<SqliteNote> for Note {
fn from(value: SqliteNote) -> Self { fn from(value: SqliteNote) -> Self {
Self { Self {
id: value.id, id: value.id,
workspace_id: value.workspace_id, _workspace_id: value.workspace_id,
slug: value.slug, slug: value.slug,
title: value.title, title: value.title,
content: value.content, content: value.content,
+3
View File
@@ -1,4 +1,5 @@
mod api; mod api;
mod auth;
mod app; mod app;
mod config; mod config;
mod database; mod database;
@@ -34,6 +35,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
config.asset_version.clone(), config.asset_version.clone(),
config.files_dir.clone(), config.files_dir.clone(),
config.upload_max_size_bytes, config.upload_max_size_bytes,
config.smtp.clone(),
config.registration_enabled,
)); ));
let app = app::router( let app = app::router(
state, state,
+25
View File
@@ -1,6 +1,31 @@
use std::{collections::HashMap, sync::{Mutex, OnceLock}}; use std::{collections::HashMap, sync::{Mutex, OnceLock}};
use crate::database::DatabaseKind; use crate::database::DatabaseKind;
// Database bootstrap and identity helpers.
pub const SQLITE_FOREIGN_KEYS_ON: &str = "PRAGMA foreign_keys = ON";
pub const SQLITE_JOURNAL_WAL: &str = "PRAGMA journal_mode = WAL";
pub const SQLITE_BUSY_TIMEOUT: &str = "PRAGMA busy_timeout = 5000";
pub const SQLITE_LAST_INSERT_ID: &str = "SELECT last_insert_rowid()";
pub const MYSQL_LAST_INSERT_ID: &str = "SELECT LAST_INSERT_ID()";
pub const POSTGRES_NOTE_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))";
pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('revisions', 'id'))";
// Authentication queries.
pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)";
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?";
pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str = "DELETE FROM password_reset_tokens WHERE user_id = ?";
pub const AUTH_INSERT_RESET_TOKEN: &str = "INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)";
pub const AUTH_FIND_RESET_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?";
pub const AUTH_UPDATE_PASSWORD: &str = "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?";
pub const AUTH_MARK_RESET_TOKEN_USED: &str = "UPDATE password_reset_tokens SET used_at = ? WHERE token = ?";
pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?";
pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?";
pub const AUTH_INSERT_SESSION: &str = "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)";
pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_hash FROM users WHERE nickname_key = ?";
pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash FROM users WHERE email_key = ?";
pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?"; pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?";
pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"; pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)";
pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"; pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC";
+7 -2
View File
@@ -4,6 +4,9 @@ use tokio::sync::{broadcast, RwLock};
const CHANNEL_CAPACITY: usize = 256; const CHANNEL_CAPACITY: usize = 256;
#[derive(Debug, Clone)]
pub struct SmtpConfig { pub host: String, pub port: u16, pub username: String, pub password: String, pub from: String, pub public_url: String }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct NoteUpdate { pub struct NoteUpdate {
pub content: String, pub content: String,
@@ -19,12 +22,14 @@ pub struct AppState {
pub asset_version: String, pub asset_version: String,
pub files_dir: String, pub files_dir: String,
pub upload_max_size_bytes: usize, pub upload_max_size_bytes: usize,
pub smtp: Option<SmtpConfig>,
pub registration_enabled: bool,
channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>, channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>,
} }
impl AppState { impl AppState {
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self { pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, smtp: Option<SmtpConfig>, registration_enabled: bool) -> Self {
Self { db, asset_version, files_dir, upload_max_size_bytes, channels: RwLock::new(HashMap::new()) } Self { db, asset_version, files_dir, upload_max_size_bytes, smtp, registration_enabled, channels: RwLock::new(HashMap::new()) }
} }
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> { async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); } if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); }
+8 -6
View File
@@ -2,12 +2,12 @@ use axum::{extract::{ws::{Message, WebSocket}, Path, State, WebSocketUpgrade}, r
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::{db, state::{NoteUpdate, SharedState}}; use crate::{auth, db, state::{NoteUpdate, SharedState}};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
enum ClientMessage { enum ClientMessage {
Authenticate { password: Option<String>, nickname: Option<String> }, Authenticate { password: Option<String>, nickname: Option<String>, session_token: Option<String> },
Update { content: String, owner_map: Option<String> }, Update { content: String, owner_map: Option<String> },
} }
@@ -26,12 +26,13 @@ pub async fn upgrade(ws: WebSocketUpgrade, Path((workspace_slug, note_slug)): Pa
async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug: String, note_slug: String) { async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug: String, note_slug: String) {
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { let _=send_error(&mut socket,"Workspace not found").await; return; }; let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { let _=send_error(&mut socket,"Workspace not found").await; return; };
let Some(note) = db::find_note(&state.db, workspace.id, &note_slug).await.ok().flatten() else { let _=send_error(&mut socket,"Note not found").await; return; }; let Some(note) = db::find_note(&state.db, workspace.id, &note_slug).await.ok().flatten() else { let _=send_error(&mut socket,"Note not found").await; return; };
let (password, nickname) = match socket.recv().await { let (password, nickname, session_token) = match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) { Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate { password, nickname }) => (password, clean_nickname(nickname)), Ok(ClientMessage::Authenticate { password, nickname, session_token }) => (password, clean_nickname(nickname), session_token),
_ => { let _=send_error(&mut socket,"Wymagane uwierzytelnienie").await; return; } _ => { let _=send_error(&mut socket,"Wymagane uwierzytelnienie").await; return; }
}, _ => return }, _ => return
}; };
let nickname = match auth::authorize_nickname(&state, nickname, session_token).await { Ok(value) => value, Err(message) => { let _=send_error(&mut socket,&message).await; return; } };
if !db::verify_workspace_password(&workspace, password.as_deref()) { let _=send_error(&mut socket,"Invalid password").await; return; } if !db::verify_workspace_password(&workspace, password.as_deref()) { let _=send_error(&mut socket,"Invalid password").await; return; }
if send(&mut socket,&ServerMessage::Authenticated { workspace_title:workspace.title.clone(), note_title:note.title.clone(), content:note.content.clone(), owner_map:note.owner_map.clone() }).await.is_err(){return;} if send(&mut socket,&ServerMessage::Authenticated { workspace_title:workspace.title.clone(), note_title:note.title.clone(), content:note.content.clone(), owner_map:note.owner_map.clone() }).await.is_err(){return;}
let channel=state.note_channel(&workspace_slug,&note_slug).await; let channel=state.note_channel(&workspace_slug,&note_slug).await;
@@ -76,12 +77,13 @@ pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path<String>,State(state
} }
async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){
let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;}; let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;};
let (password,nickname)=match socket.recv().await{ let (password,nickname,session_token)=match socket.recv().await{
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){ Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
Ok(ClientMessage::Authenticate{password,nickname})=>(password,clean_nickname(nickname)), Ok(ClientMessage::Authenticate{password,nickname,session_token})=>(password,clean_nickname(nickname),session_token),
_=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Wymagane uwierzytelnienie".into()}).await;return;} _=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Wymagane uwierzytelnienie".into()}).await;return;}
},_=>return },_=>return
}; };
let nickname=match auth::authorize_nickname(&state,nickname,session_token).await{Ok(value)=>value,Err(message)=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message}).await;return;}};
if !db::verify_pad_password(&pad,password.as_deref()){let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;} if !db::verify_pad_password(&pad,password.as_deref()){let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;}
if send_pad(&mut socket,&PadServerMessage::Authenticated{title:pad.title.clone(),content:pad.content.clone(),owner_map:pad.owner_map.clone()}).await.is_err(){return;} if send_pad(&mut socket,&PadServerMessage::Authenticated{title:pad.title.clone(),content:pad.content.clone(),owner_map:pad.owner_map.clone()}).await.is_err(){return;}
let channel=state.pad_channel(&slug).await; let channel=state.pad_channel(&slug).await;
+59 -1
View File
@@ -51,7 +51,10 @@ input { width: 100%; min-height: 46px; padding: 0 13px; border-radius: 10px; }
input:focus, select:focus, textarea:focus { border-color: #7567db; outline: 1px solid #7567db; outline-offset: 1px; } input:focus, select:focus, textarea:focus { border-color: #7567db; outline: 1px solid #7567db; outline-offset: 1px; }
.password-input { position: relative; } .password-input { position: relative; }
.password-input input { padding-right: 72px; } .password-input input { padding-right: 72px; }
.text-button { position: absolute; top: 50%; right: 8px; transform: translateY(-50%); border: 0; background: transparent; color: #a99ef8; padding: 7px; font-size: .78rem; } .text-button { position: static; transform: none; border: 0; background: transparent; color: #a99ef8; padding: 4px 2px; font-size: .78rem; }
.text-button:hover { color: #c3bbff; }
.password-toggle { position: absolute; top: 50%; right: 8px; transform: translateY(-50%); padding: 7px; }
[hidden] { display: none !important; }
.form-message { min-height: 1.2em; margin: -5px 0 0; font-size: .8rem; } .form-message { min-height: 1.2em; margin: -5px 0 0; font-size: .8rem; }
.error { color: var(--danger); } .error { color: var(--danger); }
.primary-button, .secondary-button, .inline-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; border-radius: 8px; font-weight: 650; text-decoration: none; } .primary-button, .secondary-button, .inline-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; border-radius: 8px; font-weight: 650; text-decoration: none; }
@@ -247,6 +250,9 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
.home-page { display: flex; min-height: 100vh; flex-direction: column; } .home-page { display: flex; min-height: 100vh; flex-direction: column; }
.home-page .home-layout { flex: 1 0 auto; } .home-page .home-layout { flex: 1 0 auto; }
.home-footer { flex: 0 0 auto; width: min(1040px, calc(100% - 32px)); margin: auto auto 28px; padding-top: 24px; color: var(--muted-2); font-size: .76rem; text-align: center; } .home-footer { flex: 0 0 auto; width: min(1040px, calc(100% - 32px)); margin: auto auto 28px; padding-top: 24px; color: var(--muted-2); font-size: .76rem; text-align: center; }
.home-footer__inner { display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; }
.home-footer__separator { color: var(--border-strong); }
.home-footer .text-button { font-size: inherit; text-decoration: underline; text-underline-offset: 3px; }
.home-footer a { color: var(--muted); text-decoration: none; } .home-footer a { color: var(--muted); text-decoration: none; }
.home-footer a:hover { color: white; } .home-footer a:hover { color: white; }
.home-header { border-bottom-color: rgba(195, 91, 54, .28); background: linear-gradient(135deg, rgba(126, 48, 27, .28), rgba(51, 25, 18, .08) 58%, transparent); } .home-header { border-bottom-color: rgba(195, 91, 54, .28); background: linear-gradient(135deg, rgba(126, 48, 27, .28), rgba(51, 25, 18, .08) 58%, transparent); }
@@ -466,3 +472,55 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
.public-page .task-checkbox:not(:disabled) { cursor:pointer; } .public-page .task-checkbox:not(:disabled) { cursor:pointer; }
.public-page .task-checkbox:disabled { cursor:not-allowed; opacity:.55; } .public-page .task-checkbox:disabled { cursor:not-allowed; opacity:.55; }
.identity-actions, .identity-links { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.identity-actions .primary-button { flex: 1 0 100%; }
.identity-panel { position: relative; }
.identity-panel__close { position: absolute; top: 14px; right: 14px; }
.identity-links { justify-content: space-between; }
.identity-links .text-button { padding: 3px 0; }
.auth-panel { display: grid; gap: 12px; padding-top: 14px; border-top: 1px solid var(--border); }
.auth-panel h3 { margin: 0; }
.auth-panel label { display: grid; gap: 6px; }
/* Account footer and authentication dialog. */
.home-footer { width: min(1040px, calc(100% - 32px)); margin: auto auto 24px; padding-top: 24px; color: var(--muted); font-size: .78rem; }
.home-footer__inner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-top: 18px; border-top: 1px solid var(--border); }
.home-footer__account { display: inline-flex; align-items: center; gap: 8px; }
.home-footer__user { color: var(--text); font-weight: 600; }
.home-footer__author { margin-left: auto; }
.footer-action { min-height: 34px; padding: 0 12px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--muted); font: inherit; cursor: pointer; }
.footer-action:hover { border-color: var(--border-strong); background: var(--surface-2); color: var(--text); }
.footer-action--primary { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); color: #d9d2ff; }
.app-dialog { width: min(440px, calc(100% - 28px)); padding: 0; border: 0; background: transparent; }
.app-dialog::backdrop { background: rgba(4, 6, 10, .72); backdrop-filter: blur(3px); }
.identity-panel { position: relative; display: grid; gap: 18px; width: 100%; padding: 28px; }
.identity-panel__header { padding-right: 38px; }
.identity-panel__header h2 { margin: 0 0 7px; }
.identity-panel__header p { margin: 0; }
.identity-fields { display: grid; gap: 14px; }
.identity-fields label { display: grid; gap: 7px; color: var(--muted); font-size: .78rem; }
.modal-close { position: absolute; top: 12px; right: 12px; display: grid; width: 34px; height: 34px; padding: 0; place-items: center; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted); font: 400 1.45rem/1 system-ui; cursor: pointer; }
.modal-close:hover { border-color: var(--border); background: var(--surface-2); color: white; }
.identity-links { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.identity-links .text-button { padding: 2px 0; }
.form-message.success { color: #8ed9a4; }
@media (max-width: 600px) {
.home-footer__inner { align-items: stretch; flex-direction: column; text-align: center; }
.home-footer__account { justify-content: center; }
.home-footer__author { margin-left: 0; }
.identity-panel { padding: 24px 20px 20px; }
.identity-links { align-items: flex-start; flex-direction: column; }
}
.home-footer__account[hidden], .footer-action[hidden], .identity-links [hidden], .identity-fields [hidden], .auth-panel[hidden] { display: none !important; }
.system-dialog-panel { position: relative; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
.secondary-button, .danger-button { min-height: 42px; padding: 0 16px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-2); color: var(--text); font: inherit; cursor: pointer; }
.secondary-button:hover { border-color: var(--muted); }
.danger-button { border-color: #7f3340; background: #6e2935; color: white; }
.danger-button:hover { background: #7c3040; }
/* Native hidden must win over component display declarations. */
[hidden] { display: none !important; }
.identity-panel #auth-panel:not([hidden]) { margin-top: 4px; }
.identity-panel #nickname:disabled { opacity: .55; cursor: not-allowed; }
+37 -2
View File
@@ -8,7 +8,7 @@
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"> <link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/home.js?v=__ASSET_VERSION__"></script> <script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/home.js?v=__ASSET_VERSION__"></script>
</head> </head>
<body class="home-page"> <body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a></header> <header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a></header>
<main class="home-layout home-layout--wide"> <main class="home-layout home-layout--wide">
<section class="home-intro"> <section class="home-intro">
@@ -58,6 +58,41 @@
</section> </section>
</div> </div>
</main> </main>
<footer class="home-footer">Author: <a href="https://www.linuxiarz.pl" rel="author noopener">@linuxiarz.pl</a></footer>
<footer class="home-footer">
<div class="home-footer__inner">
<div id="footer-account-guest" class="home-footer__account">
<button id="footer-login" class="footer-action" type="button">Log in</button>
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register nickname</button>
</div>
<div id="footer-account-user" class="home-footer__account" hidden>
<span id="footer-user-label" class="home-footer__user"></span>
<button id="footer-logout" class="footer-action" type="button">Log out</button>
</div>
<span class="home-footer__author">Author: <a href="https://www.linuxiarz.pl" rel="author noopener">@linuxiarz.pl</a></span>
</div>
</footer>
<dialog id="identity-dialog" class="app-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel">
<button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button>
<header class="identity-panel__header">
<h2 id="identity-title">Log in</h2>
<p id="identity-copy" class="dialog-copy"></p>
</header>
<div class="identity-fields">
<label>Nickname<input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" placeholder="Your nickname"></label>
<label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" required placeholder="you@example.com"></label>
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required placeholder="At least 8 characters"></label>
</div>
<button id="auth-submit" class="primary-button" type="submit">Log in</button>
<div class="identity-links">
<button id="show-reset" class="text-button" type="button">Forgot password?</button>
<button id="auth-switch-mode" class="text-button" type="button">Create an account</button>
<button id="reset-back" class="text-button" type="button" hidden>Back to log in</button>
</div>
<p id="identity-error" class="form-message" role="status"></p>
</form>
</dialog>
</body> </body>
</html> </html>
+6 -2
View File
@@ -5,8 +5,12 @@ export async function api(path, options = {}) {
const headers = new Headers(options.headers || {}); const headers = new Headers(options.headers || {});
if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json"); if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(path, { ...options, headers, signal: controller.signal }); const response = await fetch(path, { ...options, headers, signal: controller.signal });
const data = await response.json().catch(() => ({})); const contentType = response.headers.get("content-type") || "";
if (!response.ok) throw new Error(data.error || `Error ${response.status}`); const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {};
if (!response.ok) {
const defaults = { 400: "Invalid request.", 401: "Authentication required.", 403: "Access denied.", 404: "The requested resource was not found.", 405: "This operation is not allowed.", 409: "The requested change conflicts with existing data.", 413: "The uploaded data is too large.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 503: "Service temporarily unavailable." };
throw new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`);
}
return data; return data;
} catch (error) { } catch (error) {
if (error.name === "AbortError") throw new Error("Timed out"); if (error.name === "AbortError") throw new Error("Timed out");
+276
View File
@@ -0,0 +1,276 @@
import { api } from "@rustpad/api";
import * as sessionStore from "@rustpad/session";
import { askInput, showMessage } from "./modal.js";
const { getAuthToken, setAuthSession, setNickname } = sessionStore;
const clearAuthSession = sessionStore.clearAuthSession || (() => {
localStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:auth-token");
sessionStorage.removeItem("rustpad:nickname");
});
export function bindIdentityDialog({ dialog, onIdentity, initialMode = "login" }) {
if (dialog.querySelector("#auth-panel")) return bindLegacyIdentityDialog({ dialog, onIdentity });
const form = dialog.querySelector("#identity-form");
const nickname = dialog.querySelector("#nickname");
const message = dialog.querySelector("#identity-error");
const title = dialog.querySelector("#identity-title");
const copy = dialog.querySelector("#identity-copy");
const emailField = dialog.querySelector("#auth-email-field");
const email = dialog.querySelector("#auth-email");
const password = dialog.querySelector("#auth-password");
const submit = dialog.querySelector("#auth-submit");
const switchMode = dialog.querySelector("#auth-switch-mode");
const resetButton = dialog.querySelector("#show-reset");
const backButton = dialog.querySelector("#reset-back");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
let mode = initialMode;
const setMode = (nextMode) => {
mode = nextMode === "register" && !registrationEnabled ? "login" : nextMode;
const resetting = mode === "reset";
const registering = mode === "register";
title.textContent = resetting ? "Reset password" : registering ? "Register nickname" : "Log in";
copy.textContent = resetting
? "Enter the e-mail address assigned to your account."
: registering
? "Reserve your nickname with an e-mail address and password."
: "Use the e-mail address and password assigned to your account.";
nickname.closest("label").hidden = !registering;
nickname.disabled = !registering;
emailField.hidden = false;
email.disabled = false;
password.closest("label").hidden = resetting;
password.disabled = resetting;
submit.textContent = resetting ? "Send reset link" : registering ? "Create account" : "Log in";
switchMode.hidden = resetting || !registrationEnabled;
switchMode.textContent = registering ? "Already registered? Log in" : "Create an account";
resetButton.hidden = resetting || registering;
backButton.hidden = !resetting;
password.autocomplete = registering ? "new-password" : "current-password";
message.textContent = "";
queueMicrotask(() => {
email.focus();
});
};
switchMode?.addEventListener("click", () => setMode(mode === "register" ? "login" : "register"));
resetButton?.addEventListener("click", () => setMode("reset"));
backButton?.addEventListener("click", () => setMode("login"));
dialog.querySelector("#close-identity")?.addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
dialog.addEventListener("close", () => {
form.reset();
message.textContent = "";
setMode(initialMode);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
message.textContent = "";
submit.disabled = true;
try {
if (mode === "reset") {
const result = await api("/api/auth/password-reset", {
method: "POST",
body: JSON.stringify({ email: email.value.trim() }),
});
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
return;
}
const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login";
const payload = { email: email.value.trim(), password: password.value };
if (mode === "register") payload.nickname = nickname.value.trim();
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
setAuthSession(session);
await onIdentity(session.nickname, session);
dialog.close();
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
} finally {
submit.disabled = false;
}
});
setMode(initialMode);
return { setMode };
}
function bindLegacyIdentityDialog({ dialog, onIdentity }) {
const form = dialog.querySelector("#identity-form");
const nickname = dialog.querySelector("#nickname");
const authPanel = dialog.querySelector("#auth-panel");
const message = dialog.querySelector("#identity-error");
const modeTitle = dialog.querySelector("#auth-mode-title");
const emailField = dialog.querySelector("#auth-email-field");
const email = dialog.querySelector("#auth-email");
const password = dialog.querySelector("#auth-password");
const authSubmit = dialog.querySelector("#auth-submit");
const guestSubmit = dialog.querySelector("#guest-continue");
const registerButton = dialog.querySelector("#show-register");
const loginButton = dialog.querySelector("#show-login");
const backButton = dialog.querySelector("#auth-back");
const logoutButton = dialog.querySelector("#logout-account");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
let mode = "login";
const updateActions = () => {
if (logoutButton) logoutButton.hidden = !getAuthToken();
if (registerButton) registerButton.hidden = !registrationEnabled;
};
const collapse = () => {
authPanel.hidden = true;
nickname.disabled = false;
nickname.required = true;
password.value = "";
message.textContent = "";
guestSubmit?.focus();
};
const showAuth = (nextMode) => {
mode = nextMode === "register" && !registrationEnabled ? "login" : nextMode;
authPanel.hidden = false;
// Keep the guest nickname form visible so the user can still continue anonymously.
nickname.disabled = mode === "login";
nickname.required = mode === "register";
emailField.hidden = false;
email.disabled = false;
modeTitle.textContent = mode === "register" ? "Register nickname" : "Log in";
authSubmit.textContent = mode === "register" ? "Register and continue" : "Log in and continue";
password.autocomplete = mode === "register" ? "new-password" : "current-password";
message.textContent = "";
queueMicrotask(() => (mode === "register" ? nickname : email).focus());
};
registerButton?.addEventListener("click", () => showAuth("register"));
loginButton?.addEventListener("click", () => showAuth("login"));
backButton?.addEventListener("click", collapse);
dialog.querySelector("#close-identity")?.addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => { if (event.target === dialog) dialog.close(); });
dialog.addEventListener("close", collapse);
dialog.addEventListener("cancel", collapse);
dialog.querySelector("#show-reset")?.addEventListener("click", async () => {
const value = email.value.trim() || await askInput({
title: "Reset password",
message: "Enter the e-mail address assigned to your account.",
label: "E-mail",
type: "email",
autocomplete: "off",
confirmText: "Send reset link",
bitwardenIgnore: true,
}) || "";
if (!value) return;
try {
const result = await api("/api/auth/password-reset", { method: "POST", body: JSON.stringify({ email: value }) });
message.classList.remove("error");
message.classList.add("success");
message.textContent = result.message;
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
}
});
logoutButton?.addEventListener("click", async () => {
await logoutCurrentSession();
updateActions();
collapse();
nickname.value = "";
message.textContent = "Logged out.";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
message.textContent = "";
const name = nickname.value.trim();
const authRequested = event.submitter === authSubmit;
try {
if (authRequested) {
const endpoint = mode === "register" ? "/api/auth/register" : "/api/auth/login";
const payload = { email: email.value.trim(), password: password.value };
if (mode === "register") payload.nickname = name;
const session = await api(endpoint, { method: "POST", body: JSON.stringify(payload) });
setAuthSession(session);
await onIdentity(session.nickname, session);
return;
}
nickname.disabled = false;
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname: name, session_token: getAuthToken() || null }) });
setNickname(result.nickname);
await onIdentity(result.nickname, null);
} catch (error) {
message.classList.remove("success");
message.classList.add("error");
message.textContent = error.message;
if (/registered|session|account/i.test(error.message)) showAuth("login");
}
});
updateActions();
collapse();
return { setMode: showAuth };
}
export async function validateCurrentSession() {
const token = getAuthToken();
if (!token) return null;
try {
return await api("/api/auth/me", { headers: { Authorization: `Bearer ${token}` } });
} catch {
clearAuthSession();
return null;
}
}
export async function logoutCurrentSession() {
const token = getAuthToken();
if (token) {
try {
await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } });
} catch {}
}
clearAuthSession();
}
export async function handleResetToken() {
const url = new URL(location.href);
const token = url.searchParams.get("reset_token");
if (!token) return;
// Remove the token immediately. Refreshing or navigating back must not reopen the reset dialog.
url.searchParams.delete("reset_token");
history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`);
const password = await askInput({
title: "Set a new password",
message: "The password must contain at least 8 characters.",
label: "New password",
type: "password",
autocomplete: "off",
minLength: 8,
confirmText: "Change password",
bitwardenIgnore: true,
});
if (!password) return;
try {
await api("/api/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) });
await showMessage("Password changed. The reset link has been used and cannot be opened again.", { title: "Password changed" });
} catch (error) {
await showMessage(error.message, { title: "Password reset failed" });
}
}
+40
View File
@@ -1,3 +1,4 @@
import { bindIdentityDialog, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js";
import { api } from "@rustpad/api"; import { api } from "@rustpad/api";
function slugify(value, fallback) { function slugify(value, fallback) {
@@ -74,3 +75,42 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
setBusy(button, false, "Create workspace", "Creating…"); setBusy(button, false, "Create workspace", "Creating…");
} }
}); });
handleResetToken();
const identityDialog = document.querySelector("#identity-dialog");
const guestAccount = document.querySelector("#footer-account-guest");
const userAccount = document.querySelector("#footer-account-user");
const userLabel = document.querySelector("#footer-user-label");
const registerLink = document.querySelector("#footer-register");
const registrationEnabled = document.body.dataset.registrationEnabled === "true";
function renderAccount(session) {
guestAccount.hidden = Boolean(session);
userAccount.hidden = !session;
if (session) userLabel.textContent = `Signed in as ${session.nickname}`;
registerLink.hidden = !registrationEnabled;
}
if (identityDialog) {
const authDialog = bindIdentityDialog({
dialog: identityDialog,
onIdentity: async (_nickname, session) => renderAccount(session),
});
document.querySelector("#footer-login")?.addEventListener("click", () => {
authDialog.setMode("login");
identityDialog.showModal();
});
registerLink?.addEventListener("click", () => {
authDialog.setMode("register");
identityDialog.showModal();
});
document.querySelector("#footer-logout")?.addEventListener("click", async () => {
await logoutCurrentSession();
renderAccount(null);
});
renderAccount(null);
validateCurrentSession().then(renderAccount);
}
+62
View File
@@ -0,0 +1,62 @@
function ensureDialog() {
let dialog = document.querySelector("#system-dialog");
if (dialog) return dialog;
dialog = document.createElement("dialog");
dialog.id = "system-dialog";
dialog.className = "app-dialog";
dialog.innerHTML = `<form method="dialog" class="dialog-panel system-dialog-panel">
<button class="modal-close" value="cancel" aria-label="Close dialog">×</button>
<h2 data-title></h2><p class="dialog-copy" data-message></p>
<label data-input-wrap hidden><span data-input-label></span><input data-input name="modal-input" data-bwignore="true"></label>
<div class="dialog-actions"><button class="secondary-button" value="cancel" data-cancel>Cancel</button><button class="primary-button" value="confirm" data-confirm>OK</button></div>
</form>`;
document.body.append(dialog);
dialog.addEventListener("click", event => { if (event.target === dialog) dialog.close("cancel"); });
return dialog;
}
export function showMessage(message, { title = "Information", button = "OK" } = {}) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
dialog.querySelector("[data-input-wrap]").hidden = true;
dialog.querySelector("[data-cancel]").hidden = true;
dialog.querySelector("[data-confirm]").textContent = button;
dialog.showModal();
return new Promise(resolve => dialog.addEventListener("close", () => resolve(), { once: true }));
}
export function askConfirm(message, { title = "Confirm", confirmText = "Confirm", danger = false } = {}) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
dialog.querySelector("[data-input-wrap]").hidden = true;
dialog.querySelector("[data-cancel]").hidden = false;
const confirm = dialog.querySelector("[data-confirm]");
confirm.textContent = confirmText;
confirm.classList.toggle("danger-button", danger);
dialog.showModal();
return new Promise(resolve => dialog.addEventListener("close", () => {
confirm.classList.remove("danger-button");
resolve(dialog.returnValue === "confirm");
}, { once: true }));
}
export function askInput({ title, message = "", label, type = "text", autocomplete = "off", minLength, placeholder = "", confirmText = "Continue", bitwardenIgnore = false }) {
const dialog = ensureDialog();
dialog.querySelector("[data-title]").textContent = title;
dialog.querySelector("[data-message]").textContent = message;
const wrap = dialog.querySelector("[data-input-wrap]");
const input = dialog.querySelector("[data-input]");
wrap.hidden = false;
dialog.querySelector("[data-input-label]").textContent = label;
input.type = type; input.value = ""; input.autocomplete = autocomplete; input.placeholder = placeholder;
input.name = type === "password" ? "new-password" : type === "email" ? "email" : "modal-input";
if (bitwardenIgnore) input.setAttribute("data-bwignore", "true"); else input.removeAttribute("data-bwignore");
input.minLength = minLength || 0;
dialog.querySelector("[data-cancel]").hidden = false;
dialog.querySelector("[data-confirm]").textContent = confirmText;
dialog.showModal();
queueMicrotask(() => input.focus());
return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm" ? input.value : null), { once: true }));
}
+9 -5
View File
@@ -3,8 +3,10 @@ import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { renderMarkdown } from "@rustpad/markdown"; import { renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "./image-upload.js"; import { prepareImageFile } from "./image-upload.js";
import { getNickname, getPassword, setNickname, setPassword } from "@rustpad/session"; import { getNickname, getPassword, getAuthToken, setPassword } from "@rustpad/session";
import { bindIdentityDialog } from "./auth-ui.js";
import { NoteSocket } from "@rustpad/socket"; import { NoteSocket } from "@rustpad/socket";
import { askConfirm } from "./modal.js";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1], noteSlug=parts[3]; const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1], noteSlug=parts[3];
@@ -69,7 +71,7 @@ function replaceTableCell(line,index,value){
function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview · text and headings are editable";renderMermaid();renderCodeHighlight();}else{preview.classList.add("preview--raw");preview.innerHTML=editor.value.split("\n").map((line,index)=>`<div class="preview-source-line preview-editable" data-source-line="${index+1}" contenteditable="true" spellcheck="true">${escapeHtml(line)||"<br>"}</div>`).join("");document.querySelector("#preview-label").textContent="Text preview · editable";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();} function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview · text and headings are editable";renderMermaid();renderCodeHighlight();}else{preview.classList.add("preview--raw");preview.innerHTML=editor.value.split("\n").map((line,index)=>`<div class="preview-source-line preview-editable" data-source-line="${index+1}" contenteditable="true" spellcheck="true">${escapeHtml(line)||"<br>"}</div>`).join("");document.querySelector("#preview-label").textContent="Text preview · editable";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();}
function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();} function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`;editorWorkspace.style.setProperty("--editor-font-size",`${fontSize.value}px`);document.body.classList.toggle("compact-editor",compactToggle.checked);document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();}
function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();} function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();}
function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);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"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);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"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(/nickname|session|account/i.test(m)){if(!identityDialog.open)identityDialog.showModal();}else if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();}
function formatBytes(bytes){const value=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;} function formatBytes(bytes){const value=Number(bytes)||0;if(value<1024)return `${value} B`;if(value<1024*1024)return `${(value/1024).toFixed(1)} KB`;return `${(value/1024/1024).toFixed(1)} MB`;}
async function loadFiles({open=false}={}){ async function loadFiles({open=false}={}){
@@ -81,8 +83,10 @@ async function loadFiles({open=false}={}){
if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal(); if(open&&!document.querySelector("#files-dialog").open)document.querySelector("#files-dialog").showModal();
}catch(error){toast(error.message);} }catch(error){toast(error.message);}
} }
bindIdentityDialog({dialog:identityDialog,onIdentity:async value=>{nickname=value;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}});
identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});});
async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)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>`;}} async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)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>`;}}
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));document.querySelector("#delete-note").hidden=info.note_protected;if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}});
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
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);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();}});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);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();}});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 api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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 api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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);}}); publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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 api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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);}});
@@ -107,11 +111,11 @@ document.querySelector("#files-list").addEventListener("click",async event=>{
if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;} if(copyButton){try{await copyText(copyButton.closest(".file-code").querySelector("textarea").value);toast("Copied");}catch(error){toast(error.message);}return;}
const deleteButton=event.target.closest("[data-delete-file]"); const deleteButton=event.target.closest("[data-delete-file]");
if(deleteButton){ if(deleteButton){
if(!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`))return; if(!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`,{title:"Delete file",confirmText:"Delete",danger:true}))return;
try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return; try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});toast("File deleted");await loadFiles();}catch(error){toast(error.message);}return;
} }
}); });
document.querySelector("#delete-note").addEventListener("click",async()=>{if(!confirm(`Delete note “${info.title}”? This cannot be undone.`))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}}); document.querySelector("#delete-note").addEventListener("click",async()=>{if(!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`,{title:"Delete note",confirmText:"Delete",danger:true}))return;try{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`,{method:"DELETE",body:JSON.stringify({password:password||null})});location.assign(`/w/${encodeURIComponent(workspaceSlug)}`);}catch(error){toast(error.message);}});
window.addEventListener("error",event=>{setStatus("offline","Application error");console.error(event.error||event.message);}); window.addEventListener("error",event=>{setStatus("offline","Application error");console.error(event.error||event.message);});
window.addEventListener("unhandledrejection",event=>{setStatus("offline","Application error");console.error(event.reason);}); window.addEventListener("unhandledrejection",event=>{setStatus("offline","Application error");console.error(event.reason);});
initialize(); initialize();
+6 -3
View File
@@ -3,7 +3,8 @@ import { copyText } from "@rustpad/clipboard";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format"; import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { renderMarkdown } from "@rustpad/markdown"; import { renderMarkdown } from "@rustpad/markdown";
import { prepareImageFile } from "./image-upload.js"; import { prepareImageFile } from "./image-upload.js";
import { getNickname, setNickname } from "@rustpad/session"; import { getNickname, getAuthToken } from "@rustpad/session";
import { bindIdentityDialog } from "./auth-ui.js";
import { PadSocket } from "@rustpad/socket"; import { PadSocket } from "@rustpad/socket";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
@@ -78,9 +79,11 @@ async function loadFiles({open=false}={}){
if(open)document.querySelector("#files-dialog").showModal(); if(open)document.querySelector("#files-dialog").showModal();
}catch(error){if(open)toast(error.message);} }catch(error){if(open)toast(error.message);}
} }
function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);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"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,sessionToken:getAuthToken(),onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();applyRemote(m.content,m.owner_map);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"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;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;identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}}});
identityDialog.addEventListener("close",()=>{if(!nickname)queueMicrotask(()=>{if(!identityDialog.open)identityDialog.showModal();});});
async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)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>`;}} async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.title=`${info.title} · RustPad`;publicTaskUpdates.checked=Boolean(info.allow_public_task_updates);applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)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>`;}}
document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else{loadFiles();connect();}});
document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();}); document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();});compactToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:compact",compactToggle.checked?"on":"off");applyUi();});fontFamily.addEventListener("change",()=>{localStorage.setItem("rustpad:font-family",fontFamily.value);applyUi();});fontSize.addEventListener("change",()=>{localStorage.setItem("rustpad:font-size",fontSize.value);applyUi();});
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);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();}});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);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();}});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 api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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 api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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);}}); publicTaskUpdates.addEventListener("change",async()=>{publicTaskUpdates.disabled=true;try{await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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 api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null,allow_task_updates: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);}});
+27 -2
View File
@@ -1,5 +1,30 @@
export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspaceSlug}:password`; } export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspaceSlug}:password`; }
export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; } export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; }
export function setPassword(workspaceSlug, password) { if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); else sessionStorage.removeItem(passwordKey(workspaceSlug)); } export function setPassword(workspaceSlug, password) { if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); else sessionStorage.removeItem(passwordKey(workspaceSlug)); }
export function getNickname() { return localStorage.getItem("rustpad:nickname") || ""; } const NICKNAME_KEY = "rustpad:nickname";
export function setNickname(value) { localStorage.setItem("rustpad:nickname", value.trim()); } // Nicknames belong to the current browser session. Remove the old persistent
// value so deleting/logging out of a session cannot leave an identity behind.
localStorage.removeItem(NICKNAME_KEY);
export function getNickname() { return sessionStorage.getItem(NICKNAME_KEY) || ""; }
export function setNickname(value) {
const nickname = value.trim();
if (nickname) sessionStorage.setItem(NICKNAME_KEY, nickname);
else sessionStorage.removeItem(NICKNAME_KEY);
}
const AUTH_TOKEN_KEY = "rustpad:auth-token";
const legacyAuthToken = localStorage.getItem(AUTH_TOKEN_KEY);
if (legacyAuthToken && !sessionStorage.getItem(AUTH_TOKEN_KEY)) sessionStorage.setItem(AUTH_TOKEN_KEY, legacyAuthToken);
localStorage.removeItem(AUTH_TOKEN_KEY);
export function getAuthToken() { return sessionStorage.getItem(AUTH_TOKEN_KEY) || ""; }
export function setAuthSession(session) { sessionStorage.setItem(AUTH_TOKEN_KEY, session.token); setNickname(session.nickname); }
export function clearAuthSession() {
localStorage.removeItem(AUTH_TOKEN_KEY);
sessionStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(NICKNAME_KEY);
sessionStorage.removeItem(NICKNAME_KEY);
}
export async function resolveIdentity(api, nickname) {
const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname, session_token: getAuthToken() || null }) });
setNickname(result.nickname); return result;
}
+4 -4
View File
@@ -1,11 +1,11 @@
export class NoteSocket { export class NoteSocket {
constructor({ workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; } constructor({ workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, sessionToken, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; }
connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); } connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); }
update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); } update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); }
stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
} }
export class PadSocket { export class PadSocket {
constructor({slug,password,nickname,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;} constructor({slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,sessionToken,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;}
connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});} connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null,session_token:this.sessionToken||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});}
update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();} update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import { api } from "@rustpad/api"; import { api } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard"; import { copyText } from "@rustpad/clipboard";
import { getNickname, getPassword, setPassword } from "@rustpad/session"; import { getNickname, getPassword, setPassword } from "@rustpad/session";
import { askConfirm } from "./modal.js";
const parts = location.pathname.split("/").filter(Boolean); const parts = location.pathname.split("/").filter(Boolean);
const slug = parts[1]; const slug = parts[1];
@@ -116,7 +117,7 @@ notesList.addEventListener("click", async event => {
const button = event.target.closest("[data-delete-note]"); const button = event.target.closest("[data-delete-note]");
if (!button) return; if (!button) return;
const title = button.dataset.noteTitle; const title = button.dataset.noteTitle;
if (!confirm(`Delete note “${title}”? This cannot be undone.`)) return; if (!await askConfirm(`Delete note “${title}”? This cannot be undone.`, { title: "Delete note", confirmText: "Delete", danger: true })) return;
button.disabled = true; button.disabled = true;
try { try {
await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, { await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, {
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__NOTE_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script></head> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__NOTE_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script></head>
<body class="pad-page"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><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" 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></header> <body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><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" 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></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main> <main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></form></dialog> <dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><h2>What should we call you?</h2><p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required placeholder="Name or nickname"><div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as guest</button><button id="show-register" class="text-button" type="button">Register</button><button id="show-login" class="text-button" type="button">Log in</button></div><section id="auth-panel" class="auth-panel" hidden><h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button id="auth-submit" class="primary-button" type="submit">Log in and continue</button><div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot password?</button><button id="auth-back" class="text-button" type="button">Back to nickname</button><button id="logout-account" class="text-button" type="button">Log out saved account</button></div></section><p id="identity-error" class="form-message error" role="alert"></p></form></dialog>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a id="back-workspace" class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html> <dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a id="back-workspace" class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html>
+2 -2
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__PAD_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script></head> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__PAD_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script></head>
<body class="pad-page"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><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" class="secondary-button">Files</button><button id="history-button" class="secondary-button">History</button></div></header> <body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span id="current-user" class="user-chip"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><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" class="secondary-button">Files</button><button id="history-button" class="secondary-button">History</button></div></header>
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main> <main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" class="dialog-panel"><h2>What should we call you?</h2><p class="dialog-copy">Your name will be shown next to changes and remembered on this device.</p><input id="nickname" maxlength="40" autocomplete="nickname" required placeholder="Name or nickname"><button class="primary-button">Open note</button></form></dialog> <dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><h2>What should we call you?</h2><p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required placeholder="Name or nickname"><div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as guest</button><button id="show-register" class="text-button" type="button">Register</button><button id="show-login" class="text-button" type="button">Log in</button></div><section id="auth-panel" class="auth-panel" hidden><h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button id="auth-submit" class="primary-button" type="submit">Log in and continue</button><div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot password?</button><button id="auth-back" class="text-button" type="button">Back to nickname</button><button id="logout-account" class="text-button" type="button">Log out saved account</button></div></section><p id="identity-error" class="form-message error" role="alert"></p></form></dialog>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html> <dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html>