big features
This commit is contained in:
+10
-1
@@ -34,4 +34,13 @@ POSTGRES_PASSWORD=rustpad
|
||||
MYSQL_DATABASE=rustpad
|
||||
MYSQL_USER=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
@@ -38,6 +38,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "atoi"
|
||||
version = "2.0.0"
|
||||
@@ -315,7 +326,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -333,6 +344,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -380,6 +407,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -464,7 +497,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -841,6 +874,33 @@ dependencies = [
|
||||
"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]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@@ -972,6 +1032,15 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1162,6 +1231,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -1302,6 +1377,7 @@ version = "0.23.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -1339,13 +1415,16 @@ dependencies = [
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
"lettre",
|
||||
"mime_guess",
|
||||
"rand_core 0.6.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"slug",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -1396,7 +1475,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1606,7 +1685,7 @@ dependencies = [
|
||||
"quote",
|
||||
"sqlx-core",
|
||||
"sqlx-macros-core",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1629,7 +1708,7 @@ dependencies = [
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
"sqlx-sqlite",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
@@ -1774,6 +1853,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -1788,7 +1878,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1808,7 +1898,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1869,7 +1959,17 @@ checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"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]]
|
||||
@@ -1982,7 +2082,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2174,7 +2274,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -2236,7 +2336,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2247,7 +2347,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2462,7 +2562,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -2483,7 +2583,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2503,7 +2603,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -2543,7 +2643,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+4
-1
@@ -13,12 +13,15 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
futures-util = "0.3"
|
||||
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"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
slug = "0.1"
|
||||
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"] }
|
||||
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-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -14,7 +14,14 @@ services:
|
||||
FILES_DIR: ${FILES_DIR:-/data/files}
|
||||
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20}
|
||||
ASSET_VERSION: ${ASSET_VERSION:-dev}
|
||||
REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false}
|
||||
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:
|
||||
- "${RUSTPAD_PORT:-3000}:${APP_PORT:-3000}"
|
||||
volumes:
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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
@@ -5,9 +5,10 @@ use axum::{
|
||||
routing::{get, post},
|
||||
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 {
|
||||
Router::new()
|
||||
@@ -19,6 +20,13 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
|
||||
.route("/health", get(health))
|
||||
.route("/f/{token}/{filename}", get(api::download_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}/tasks", post(api::update_public_task))
|
||||
.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/{*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)
|
||||
.method_not_allowed_fallback(method_not_allowed)
|
||||
.layer(DefaultBodyLimit::max(upload_max_size_bytes.saturating_add(1024 * 1024)))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
@@ -74,7 +91,7 @@ async fn health() -> &'static str {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -85,7 +102,7 @@ async fn pad(
|
||||
Ok(Some(pad)) => {
|
||||
let html = include_str!("../static/pad.html")
|
||||
.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(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -108,7 +125,7 @@ async fn public_page(
|
||||
Path(token): Path<String>,
|
||||
) -> Response {
|
||||
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(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
@@ -133,7 +150,7 @@ async fn workspace(
|
||||
Ok(Some(workspace)) => {
|
||||
let html = include_str!("../static/workspace.html")
|
||||
.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(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -180,7 +197,7 @@ async fn note(
|
||||
.replace("__NOTE_TITLE__", &escape_html(¬e.title))
|
||||
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
|
||||
.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(
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -198,13 +215,28 @@ async fn note(
|
||||
}
|
||||
}
|
||||
|
||||
async fn static_not_found() -> Response {
|
||||
let mut response = (StatusCode::NOT_FOUND, "404").into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
async fn static_not_found(State(state): State<SharedState>) -> Response {
|
||||
error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"404",
|
||||
"File not found",
|
||||
"The requested static file does not exist.",
|
||||
"/",
|
||||
"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 {
|
||||
@@ -253,8 +285,10 @@ fn error_response(
|
||||
response
|
||||
}
|
||||
|
||||
fn versioned_html(template: &str, asset_version: &str) -> Response {
|
||||
let html = template.replace("__ASSET_VERSION__", asset_version);
|
||||
fn versioned_html(template: &str, asset_version: &str, registration_enabled: bool) -> Response {
|
||||
let html = template
|
||||
.replace("__ASSET_VERSION__", asset_version)
|
||||
.replace("__REGISTRATION_ENABLED__", if registration_enabled { "true" } else { "false" });
|
||||
let mut response = Html(html).into_response();
|
||||
no_store(&mut response);
|
||||
response
|
||||
|
||||
+188
@@ -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
@@ -10,6 +10,8 @@ pub struct Config {
|
||||
pub files_dir: String,
|
||||
pub upload_max_size_bytes: usize,
|
||||
pub asset_version: String,
|
||||
pub smtp: Option<crate::state::SmtpConfig>,
|
||||
pub registration_enabled: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -26,6 +28,18 @@ impl Config {
|
||||
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 {
|
||||
host,
|
||||
port,
|
||||
@@ -39,7 +53,9 @@ impl Config {
|
||||
upload_max_size_bytes: upload_max_size_mb
|
||||
.checked_mul(1024 * 1024)
|
||||
.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 {
|
||||
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
@@ -1,3 +1,4 @@
|
||||
use crate::queries;
|
||||
use sqlx::{any::AnyPoolOptions, AnyPool};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -22,9 +23,9 @@ impl Database {
|
||||
.connect(url)
|
||||
.await?;
|
||||
if kind == DatabaseKind::Sqlite {
|
||||
sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA journal_mode = WAL").execute(&pool).await?;
|
||||
sqlx::query("PRAGMA busy_timeout = 5000").execute(&pool).await?;
|
||||
sqlx::query(queries::SQLITE_FOREIGN_KEYS_ON).execute(&pool).await?;
|
||||
sqlx::query(queries::SQLITE_JOURNAL_WAL).execute(&pool).await?;
|
||||
sqlx::query(queries::SQLITE_BUSY_TIMEOUT).execute(&pool).await?;
|
||||
}
|
||||
Ok(Self { pool, kind })
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ use sqlx::{Any, Transaction};
|
||||
|
||||
async fn inserted_id(kind: DatabaseKind, tx: &mut Transaction<'_, Any>, table: &str) -> Result<i64, sqlx::Error> {
|
||||
let query = match kind {
|
||||
DatabaseKind::Sqlite => "SELECT last_insert_rowid()",
|
||||
DatabaseKind::MySql => "SELECT LAST_INSERT_ID()",
|
||||
DatabaseKind::Sqlite => queries::SQLITE_LAST_INSERT_ID,
|
||||
DatabaseKind::MySql => queries::MYSQL_LAST_INSERT_ID,
|
||||
DatabaseKind::Postgres => match table {
|
||||
"note_revisions" => "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))",
|
||||
"revisions" => "SELECT currval(pg_get_serial_sequence('revisions', 'id'))",
|
||||
"note_revisions" => queries::POSTGRES_NOTE_REVISION_LAST_INSERT_ID,
|
||||
"revisions" => queries::POSTGRES_PAD_REVISION_LAST_INSERT_ID,
|
||||
_ => unreachable!("unsupported identity table"),
|
||||
},
|
||||
};
|
||||
@@ -36,7 +36,8 @@ pub struct Workspace {
|
||||
pub struct Note {
|
||||
pub id: i64,
|
||||
#[serde(skip_serializing)]
|
||||
pub workspace_id: i64,
|
||||
#[sqlx(rename = "workspace_id")]
|
||||
pub _workspace_id: i64,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing)]
|
||||
@@ -66,7 +67,7 @@ impl From<SqliteNote> for Note {
|
||||
fn from(value: SqliteNote) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
workspace_id: value.workspace_id,
|
||||
_workspace_id: value.workspace_id,
|
||||
slug: value.slug,
|
||||
title: value.title,
|
||||
content: value.content,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod api;
|
||||
mod auth;
|
||||
mod app;
|
||||
mod config;
|
||||
mod database;
|
||||
@@ -34,6 +35,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
config.asset_version.clone(),
|
||||
config.files_dir.clone(),
|
||||
config.upload_max_size_bytes,
|
||||
config.smtp.clone(),
|
||||
config.registration_enabled,
|
||||
));
|
||||
let app = app::router(
|
||||
state,
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
use std::{collections::HashMap, sync::{Mutex, OnceLock}};
|
||||
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 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";
|
||||
|
||||
+7
-2
@@ -4,6 +4,9 @@ use tokio::sync::{broadcast, RwLock};
|
||||
|
||||
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)]
|
||||
pub struct NoteUpdate {
|
||||
pub content: String,
|
||||
@@ -19,12 +22,14 @@ pub struct AppState {
|
||||
pub asset_version: String,
|
||||
pub files_dir: String,
|
||||
pub upload_max_size_bytes: usize,
|
||||
pub smtp: Option<SmtpConfig>,
|
||||
pub registration_enabled: bool,
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<NoteUpdate>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self {
|
||||
Self { db, asset_version, files_dir, upload_max_size_bytes, channels: RwLock::new(HashMap::new()) }
|
||||
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, smtp, registration_enabled, channels: RwLock::new(HashMap::new()) }
|
||||
}
|
||||
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
|
||||
if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); }
|
||||
|
||||
+8
-6
@@ -2,12 +2,12 @@ use axum::{extract::{ws::{Message, WebSocket}, Path, State, WebSocketUpgrade}, r
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, warn};
|
||||
use crate::{db, state::{NoteUpdate, SharedState}};
|
||||
use crate::{auth, db, state::{NoteUpdate, SharedState}};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
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> },
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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, ¬e_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) {
|
||||
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; }
|
||||
}, _ => 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 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,¬e_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){
|
||||
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){
|
||||
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;}
|
||||
},_=>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 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;
|
||||
|
||||
+59
-1
@@ -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; }
|
||||
.password-input { position: relative; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -247,6 +250,9 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
|
||||
.home-page { display: flex; min-height: 100vh; flex-direction: column; }
|
||||
.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__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: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); }
|
||||
@@ -466,3 +472,55 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
|
||||
.public-page .task-checkbox:not(:disabled) { cursor:pointer; }
|
||||
.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
@@ -8,7 +8,7 @@
|
||||
<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>
|
||||
</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>
|
||||
<main class="home-layout home-layout--wide">
|
||||
<section class="home-intro">
|
||||
@@ -58,6 +58,41 @@
|
||||
</section>
|
||||
</div>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
+6
-2
@@ -5,8 +5,12 @@ export async function api(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
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 data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Error ${response.status}`);
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
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;
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") throw new Error("Timed out");
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindIdentityDialog, handleResetToken, logoutCurrentSession, validateCurrentSession } from "./auth-ui.js";
|
||||
import { api } from "@rustpad/api";
|
||||
|
||||
function slugify(value, fallback) {
|
||||
@@ -74,3 +75,42 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -3,8 +3,10 @@ import { copyText } from "@rustpad/clipboard";
|
||||
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
|
||||
import { renderMarkdown } from "@rustpad/markdown";
|
||||
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 { askConfirm } from "./modal.js";
|
||||
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
|
||||
|
||||
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 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 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`;}
|
||||
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();
|
||||
}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>`;}}
|
||||
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();});
|
||||
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);}});
|
||||
@@ -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;}
|
||||
const deleteButton=event.target.closest("[data-delete-file]");
|
||||
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;
|
||||
}
|
||||
});
|
||||
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("unhandledrejection",event=>{setStatus("offline","Application error");console.error(event.reason);});
|
||||
initialize();
|
||||
|
||||
+6
-3
@@ -3,7 +3,8 @@ import { copyText } from "@rustpad/clipboard";
|
||||
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
|
||||
import { renderMarkdown } from "@rustpad/markdown";
|
||||
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 { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
|
||||
|
||||
@@ -78,9 +79,11 @@ async function loadFiles({open=false}={}){
|
||||
if(open)document.querySelector("#files-dialog").showModal();
|
||||
}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>`;}}
|
||||
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();});
|
||||
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);}});
|
||||
|
||||
+27
-2
@@ -1,5 +1,30 @@
|
||||
export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspaceSlug}:password`; }
|
||||
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 getNickname() { return localStorage.getItem("rustpad:nickname") || ""; }
|
||||
export function setNickname(value) { localStorage.setItem("rustpad:nickname", value.trim()); }
|
||||
const NICKNAME_KEY = "rustpad:nickname";
|
||||
// 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
@@ -1,11 +1,11 @@
|
||||
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; }
|
||||
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();}); }
|
||||
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,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})); }
|
||||
stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();}
|
||||
}
|
||||
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;}
|
||||
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();});}
|
||||
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,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();}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api } from "@rustpad/api";
|
||||
import { copyText } from "@rustpad/clipboard";
|
||||
import { getNickname, getPassword, setPassword } from "@rustpad/session";
|
||||
import { askConfirm } from "./modal.js";
|
||||
|
||||
const parts = location.pathname.split("/").filter(Boolean);
|
||||
const slug = parts[1];
|
||||
@@ -116,7 +117,7 @@ notesList.addEventListener("click", async event => {
|
||||
const button = event.target.closest("[data-delete-note]");
|
||||
if (!button) return;
|
||||
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;
|
||||
try {
|
||||
await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(button.dataset.deleteNote)}`, {
|
||||
|
||||
+2
-2
@@ -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>
|
||||
<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>
|
||||
<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 H1–H4</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 H1–H4</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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
<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>
|
||||
<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 H1–H4</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 H1–H4</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>
|
||||
|
||||
Reference in New Issue
Block a user