From 8c3842157d9c4b358cce38c0b1900aa1c10ce0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 23 Jul 2026 15:07:59 +0200 Subject: [PATCH] share funxtions --- .env.example | 1 - Cargo.lock | 2 +- Cargo.toml | 2 +- migrations/mysql/0010_private_sharing.sql | 24 ++++ migrations/postgres/0010_private_sharing.sql | 22 ++++ migrations/sqlite/0010_private_sharing.sql | 22 ++++ src/api.rs | 15 ++- src/app.rs | 17 +++ src/auth.rs | 125 ++++++++++++++++++- src/db.rs | 2 + src/queries.rs | 8 +- src/websocket.rs | 19 ++- static/css/styles.css | 89 +++++++++++++ static/js/api.js | 1 + static/js/home.js | 70 ++++++++++- static/js/note.js | 3 +- static/js/pad.js | 3 +- static/js/workspace.js | 13 +- 18 files changed, 409 insertions(+), 29 deletions(-) create mode 100644 migrations/mysql/0010_private_sharing.sql create mode 100644 migrations/postgres/0010_private_sharing.sql create mode 100644 migrations/sqlite/0010_private_sharing.sql diff --git a/.env.example b/.env.example index 8e2a6cc..23f34a2 100644 --- a/.env.example +++ b/.env.example @@ -58,4 +58,3 @@ SMTP_PORT=587 SMTP_USERNAME= SMTP_PASSWORD= SMTP_FROM="RustPad " - diff --git a/Cargo.lock b/Cargo.lock index 08640c2..a704d39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1408,7 +1408,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.8" +version = "0.0.9" dependencies = [ "argon2", "axum", diff --git a/Cargo.toml b/Cargo.toml index dcdbbda..e216dfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.9" +version = "0.0.10" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/migrations/mysql/0010_private_sharing.sql b/migrations/mysql/0010_private_sharing.sql new file mode 100644 index 0000000..b227473 --- /dev/null +++ b/migrations/mysql/0010_private_sharing.sql @@ -0,0 +1,24 @@ +ALTER TABLE workspaces ADD COLUMN is_private BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE pads ADD COLUMN is_private BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE resource_permissions ( + resource_kind VARCHAR(16) NOT NULL, + resource_slug VARCHAR(255) NOT NULL, + user_id BIGINT NOT NULL, + permission VARCHAR(2) NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY(resource_kind, resource_slug, user_id), + CONSTRAINT fk_resource_permissions_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX idx_resource_permissions_user ON resource_permissions(user_id); +CREATE TABLE resource_share_links ( + token_hash VARCHAR(64) PRIMARY KEY, + resource_kind VARCHAR(16) NOT NULL, + resource_slug VARCHAR(255) NOT NULL, + permission VARCHAR(2) NOT NULL, + expires_at TEXT NULL, + created_by BIGINT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + revoked_at TEXT NULL, + CONSTRAINT fk_resource_share_links_user FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX idx_resource_share_links_resource ON resource_share_links(resource_kind, resource_slug); diff --git a/migrations/postgres/0010_private_sharing.sql b/migrations/postgres/0010_private_sharing.sql new file mode 100644 index 0000000..36a2a49 --- /dev/null +++ b/migrations/postgres/0010_private_sharing.sql @@ -0,0 +1,22 @@ +ALTER TABLE workspaces ADD COLUMN is_private BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE pads ADD COLUMN is_private BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE resource_permissions ( + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission TEXT NOT NULL CHECK(permission IN ('ro','rw')), + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + PRIMARY KEY(resource_kind, resource_slug, user_id) +); +CREATE INDEX idx_resource_permissions_user ON resource_permissions(user_id); +CREATE TABLE resource_share_links ( + token_hash TEXT PRIMARY KEY, + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + permission TEXT NOT NULL CHECK(permission IN ('ro','rw')), + expires_at TEXT, + created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + revoked_at TEXT +); +CREATE INDEX idx_resource_share_links_resource ON resource_share_links(resource_kind, resource_slug); diff --git a/migrations/sqlite/0010_private_sharing.sql b/migrations/sqlite/0010_private_sharing.sql new file mode 100644 index 0000000..40a0e58 --- /dev/null +++ b/migrations/sqlite/0010_private_sharing.sql @@ -0,0 +1,22 @@ +ALTER TABLE workspaces ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0; +ALTER TABLE pads ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0; +CREATE TABLE resource_permissions ( + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission TEXT NOT NULL CHECK(permission IN ('ro','rw')), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(resource_kind, resource_slug, user_id) +); +CREATE INDEX idx_resource_permissions_user ON resource_permissions(user_id); +CREATE TABLE resource_share_links ( + token_hash TEXT PRIMARY KEY, + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + permission TEXT NOT NULL CHECK(permission IN ('ro','rw')), + expires_at TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at TEXT +); +CREATE INDEX idx_resource_share_links_resource ON resource_share_links(resource_kind, resource_slug); diff --git a/src/api.rs b/src/api.rs index 3418742..ad8efa5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -295,9 +295,9 @@ pub async fn authorized_workspace( let workspace = db::find_workspace(&state.db, slug) .await? .ok_or_else(ApiError::not_found_workspace)?; - if workspace.password_hash.is_some() - && !db::verify_workspace_password(&workspace, password) - && !verify_resource_access_token(state, "workspace", slug, access_token).await? { + let token_access = verify_resource_access_token(state, "workspace", slug, access_token).await?; + if workspace.is_private != 0 && !token_access { return Err(ApiError::forbidden("This workspace is private.")); } + if workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password) && !token_access { return Err(ApiError::unauthorized()); } Ok(workspace) @@ -566,9 +566,9 @@ async fn authorized_pad( let pad = db::find_pad(&state.db, slug) .await? .ok_or_else(ApiError::not_found_note)?; - if pad.password_hash.is_some() - && !db::verify_pad_password(&pad, password) - && !verify_resource_access_token(state, "pad", slug, access_token).await? { + let token_access = verify_resource_access_token(state, "pad", slug, access_token).await?; + if pad.is_private != 0 && !token_access { return Err(ApiError::forbidden("This note is private.")); } + if pad.password_hash.is_some() && !db::verify_pad_password(&pad, password) && !token_access { return Err(ApiError::unauthorized()); } Ok(pad) @@ -872,6 +872,9 @@ pub async fn verify_resource_access_token( let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(false); }; + if crate::auth::resource_permission(state, kind, slug, Some(token)).await.map_err(|error| ApiError::forbidden(&error.message))?.is_some() { + return Ok(true); + } let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?")) .bind(hash_access_token(token)) .bind(kind) diff --git a/src/app.rs b/src/app.rs index 7f446fd..52ff6cb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -37,6 +37,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/s/{token}", get(public_page)) .route("/w/{workspace_slug}", get(workspace)) .route("/w/{workspace_slug}/n/{note_slug}", get(note)) + .route("/errors/private-workspace", get(private_workspace_error)) .route("/health", get(health)) .route("/f/{token}/{filename}", get(api::download_file)) .route("/files/{directory}/{filename}", get(api::download_legacy_file)) @@ -48,6 +49,9 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/api/auth/me", get(auth::me)) .route("/api/auth/logout", post(auth::logout)) .route("/api/auth/resources", get(auth::resources).put(auth::update_resource).delete(auth::delete_resource)) + .route("/api/auth/resources/privacy", post(auth::set_resource_privacy)) + .route("/api/auth/resources/sharing", get(auth::resource_sharing).post(auth::share_resource_users).delete(auth::remove_resource_user)) + .route("/api/auth/resources/share-links", post(auth::create_share_link).put(auth::update_share_link).delete(auth::revoke_share_link)) .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)) @@ -109,6 +113,19 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .with_state(state) } + +async fn private_workspace_error(State(state): State) -> Response { + error_response( + StatusCode::FORBIDDEN, + "403", + "Private workspace", + "You do not have permission to access this private workspace. Ask the owner to share it with your account or use a valid share link.", + "/", + "Home page", + &state.asset_version, + ) +} + async fn health() -> &'static str { "ok" } diff --git a/src/auth.rs b/src/auth.rs index 731ab91..41d857b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -37,8 +37,18 @@ pub struct User { kind: String, slug: String, #[serde(default)] password: Option } #[derive(Serialize, FromRow)] pub struct ResourceItem { - slug: String, title: String, protected: i64, updated_at: String + slug: String, title: String, protected: i64, updated_at: String, #[sqlx(rename = "private")] + #[serde(rename = "private")] + private_resource: i64, owned: i64, permission: String, shared_by: String } + +#[derive(Deserialize)] pub struct PrivacyRequest { kind: String, slug: String, private: bool } +#[derive(Deserialize)] pub struct ShareUsersRequest { kind: String, slug: String, emails: String, permission: String } +#[derive(Deserialize)] pub struct RemoveShareRequest { kind: String, slug: String, email: String } +#[derive(Deserialize)] pub struct CreateShareLinkRequest { kind: String, slug: String, permission: String, expires_at: Option } +#[derive(Deserialize)] pub struct UpdateShareLinkRequest { kind: String, slug: String, token: String, permission: String, expires_at: Option } +#[derive(Deserialize)] pub struct RevokeShareLinkRequest { kind: String, slug: String, token: String } + #[derive(Serialize)] pub struct ResourceList { workspaces: Vec, pads: Vec } @@ -161,8 +171,8 @@ pub async fn me(State(state): State, headers: HeaderMap) -> Result< pub async fn resources(State(state): State, headers: HeaderMap) -> Result, AuthError> { let user = require_user(&state, &headers).await?; - let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_WORKSPACES)).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; - let pads = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; + let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_WORKSPACES)).bind(user.id).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; + let pads = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)).bind(user.id).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; Ok(Json(ResourceList { workspaces, pads })) } @@ -202,6 +212,115 @@ async fn ensure_owner(state: &SharedState, user_id: i64, kind: &str, slug: &str) Ok(()) } + +pub async fn set_resource_privacy(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { + let user = require_user(&state, &headers).await?; + ensure_owner(&state, user.id, &req.kind, &req.slug).await?; + let table = match req.kind.as_str() { "workspace" => "workspaces", "pad" => "pads", _ => return Err(AuthError::bad_request("Unknown resource type.")) }; + let query = format!("UPDATE {table} SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"); + sqlx::query(&query).bind(req.private).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + Ok(Json(serde_json::json!({"ok":true}))) +} + +pub async fn share_resource_users(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { + let owner = require_user(&state, &headers).await?; + ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; + let permission = validate_permission(&req.permission)?; + let emails: Vec = req.emails.split(',').map(|v| normalize(v)).filter(|v| !v.is_empty()).collect(); + if emails.is_empty() || emails.len() > 100 { return Err(AuthError::bad_request("Enter between 1 and 100 registered e-mail addresses.")); } + let mut missing = Vec::new(); + for email in emails { + let user = find_user_by_email(&state, &email).await?; + let Some(user) = user else { missing.push(email); continue; }; + if user.id == owner.id { continue; } + sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) + .bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?; + } + if !missing.is_empty() { return Err(AuthError::bad_request(&format!("No registered account for: {}", missing.join(", ")))); } + Ok(Json(serde_json::json!({"ok":true}))) +} + +pub async fn remove_resource_user(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { + let owner = require_user(&state, &headers).await?; + ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; + let email = normalize(&req.email); + if let Some(user) = find_user_by_email(&state, &email).await? { + sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; + } + Ok(Json(serde_json::json!({"ok":true}))) +} + +pub async fn resource_sharing(State(state): State, headers: HeaderMap, axum::extract::Query(params): axum::extract::Query>) -> Result, AuthError> { + let owner = require_user(&state, &headers).await?; + let kind = params.get("kind").ok_or_else(|| AuthError::bad_request("Missing kind."))?; + let slug = params.get("slug").ok_or_else(|| AuthError::bad_request("Missing slug."))?; + ensure_owner(&state, owner.id, kind, slug).await?; + let users: Vec<(String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email")) + .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; + let links: Vec<(String,String,Option,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC")) + .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; + Ok(Json(serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::>() }))) +} + +pub async fn create_share_link(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { + let owner = require_user(&state, &headers).await?; + ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; + let permission = validate_permission(&req.permission)?; + validate_share_expiration(req.expires_at.as_deref())?; + let token = random_token(); let token_hash = hash_token(&token); + sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)")) + .bind(token_hash).bind(&req.kind).bind(req.slug.trim()).bind(permission).bind(&req.expires_at).bind(owner.id).execute(state.db.pool()).await.map_err(AuthError::database)?; + let base = if req.kind == "workspace" { format!("/w/{}", req.slug.trim()) } else { format!("/p/{}", req.slug.trim()) }; + Ok(Json(serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":req.expires_at}))) +} + + +pub async fn update_share_link(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { + let owner = require_user(&state, &headers).await?; + ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; + let permission = validate_permission(&req.permission)?; + validate_share_expiration(req.expires_at.as_deref())?; + let result = sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL")) + .bind(permission).bind(&req.expires_at).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + if result.rows_affected() == 0 { return Err(AuthError::bad_request("Share link was not found or is already revoked.")); } + Ok(Json(serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at}))) +} + +pub async fn revoke_share_link(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { + let owner = require_user(&state, &headers).await?; + ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; + sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?")) + .bind(Utc::now().to_rfc3339()).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + Ok(Json(serde_json::json!({"ok":true}))) +} + +fn validate_permission(value: &str) -> Result<&str, AuthError> { match value { "ro"|"rw" => Ok(value), _ => Err(AuthError::bad_request("Permission must be ro or rw.")) } } + +fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> { + let Some(value) = value else { return Ok(()); }; + let expires = chrono::DateTime::parse_from_rfc3339(value).map_err(|_| AuthError::bad_request("Invalid expiration date."))?.with_timezone(&Utc); + if expires <= Utc::now() { return Err(AuthError::bad_request("Expiration must be in the future.")); } + Ok(()) +} + +pub async fn resource_permission(state: &SharedState, kind: &str, slug: &str, token: Option<&str>) -> Result, AuthError> { + let Some(token) = token.filter(|v| !v.is_empty()) else { return Ok(None); }; + if let Some(user) = user_from_token(state, token).await? { + let owns = ensure_owner(state, user.id, kind, slug).await.is_ok(); + if owns { return Ok(Some("rw".into())); } + let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + .bind(kind).bind(slug).bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; + return Ok(permission); + } + let now = Utc::now().to_rfc3339(); + let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)")) + .bind(hash_token(token)).bind(kind).bind(slug).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; + Ok(permission) +} + pub async fn logout(State(state): State, headers: HeaderMap) -> Result, AuthError> { if let Some(token) = bearer(&headers) { let result = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN)).bind(token).execute(state.db.pool()).await.map_err(AuthError::database)?; diff --git a/src/db.rs b/src/db.rs index 1b06aa4..63453e3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -30,6 +30,7 @@ pub struct Workspace { pub password_hash: Option, pub created_at: String, pub updated_at: String, + pub is_private: i64, } #[derive(Debug, Clone, Serialize, FromRow)] @@ -299,6 +300,7 @@ pub struct Pad { pub created_at: String, pub updated_at: String, pub owner_map: String, + pub is_private: i64, } pub async fn find_pad(pool: &Database, slug: &str) -> Result, sqlx::Error> { diff --git a/src/queries.rs b/src/queries.rs index 2f6b599..3220f03 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -33,8 +33,8 @@ pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_ha pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE email_key = ?"; pub const USER_ATTACH_WORKSPACE: &str = "INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"; pub const USER_ATTACH_PAD: &str = "INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"; -pub const USER_LIST_WORKSPACES: &str = "SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? ORDER BY w.updated_at DESC"; -pub const USER_LIST_PADS: &str = "SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? ORDER BY p.updated_at DESC"; +pub const USER_LIST_WORKSPACES: &str = "SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"; +pub const USER_LIST_PADS: &str = "SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"; pub const USER_OWNS_WORKSPACE: &str = "SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"; pub const USER_OWNS_PAD: &str = "SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? AND p.slug = ?"; pub const USER_DELETE_WORKSPACE: &str = "DELETE FROM workspaces WHERE slug = ?"; @@ -43,7 +43,7 @@ pub const USER_SET_WORKSPACE_PASSWORD: &str = "UPDATE workspaces SET password_ha pub const USER_SET_PAD_PASSWORD: &str = "UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; -pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?"; +pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private 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"; pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?"; @@ -53,7 +53,7 @@ pub const Q007: &str = "UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHE pub const Q008: &str = "INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; pub const Q009: &str = "SELECT updated_at FROM notes WHERE id = ?"; pub const Q010: &str = "SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"; -pub const Q011: &str = "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE slug = ?"; +pub const Q011: &str = "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM pads WHERE slug = ?"; pub const Q012: &str = "INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"; pub const Q013: &str = "UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; pub const Q014: &str = "INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; diff --git a/src/websocket.rs b/src/websocket.rs index 1ca8bd3..b1cfe27 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -40,8 +40,12 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug _ => { 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 workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password.as_deref()) && !crate::api::verify_resource_access_token(&state, "workspace", &workspace_slug, access_token.as_deref()).await.unwrap_or(false) { warn!(workspace_id = workspace.id, note_id = note.id, "note websocket rejected: invalid workspace password"); let _=send_error(&mut socket,"Invalid password").await; return; } + let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await { Ok(value) => value, Err(message) => { let _=send_error(&mut socket,&message).await; return; } }; + let permission = auth::resource_permission(&state, "workspace", &workspace_slug, session_token.as_deref().or(access_token.as_deref())).await.ok().flatten(); + let password_ok = db::verify_workspace_password(&workspace, password.as_deref()); + if workspace.is_private != 0 && permission.is_none() { let _=send_error(&mut socket,"This workspace is private").await; return; } + if workspace.password_hash.is_some() && !password_ok && permission.is_none() { warn!(workspace_id = workspace.id, note_id = note.id, "note websocket rejected: invalid workspace password"); let _=send_error(&mut socket,"Invalid password").await; return; } + let write_allowed = password_ok || permission.as_deref() != Some("ro"); info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated"); 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 room_key = AppState::note_room_key(&workspace_slug, ¬e_slug); @@ -56,6 +60,7 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug incoming=receiver.next()=>match incoming { Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text) { Ok(ClientMessage::Update{content,owner_map})=>{ + if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; } if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; } let owner_map=owner_map.unwrap_or_else(||"[]".into()); match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await { @@ -129,8 +134,12 @@ async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ _=>{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 pad.password_hash.is_some() && !db::verify_pad_password(&pad,password.as_deref()) && !crate::api::verify_resource_access_token(&state,"pad",&slug,access_token.as_deref()).await.unwrap_or(false){warn!(pad_id = pad.id, "pad websocket rejected: invalid password");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;} + let nickname=match auth::authorize_nickname(&state,nickname,session_token.clone()).await{Ok(value)=>value,Err(message)=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message}).await;return;}}; + let permission=auth::resource_permission(&state,"pad",&slug,session_token.as_deref().or(access_token.as_deref())).await.ok().flatten(); + let password_ok=db::verify_pad_password(&pad,password.as_deref()); + if pad.is_private != 0 && permission.is_none(){let _=send_pad(&mut socket,&PadServerMessage::Error{message:"This note is private".into()}).await;return;} + if pad.password_hash.is_some() && !password_ok && permission.is_none(){warn!(pad_id = pad.id, "pad websocket rejected: invalid password");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;} + let write_allowed=password_ok || permission.as_deref()!=Some("ro"); info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated"); 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 room_key = AppState::pad_room_key(&slug); @@ -144,7 +153,7 @@ async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ loop{tokio::select!{ incoming=receiver.next()=>match incoming{ Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text){ - Ok(ClientMessage::Update{content,owner_map})=>{ + Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;} if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; } let owner_map=owner_map.unwrap_or_else(||"[]".into()); if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{ diff --git a/static/css/styles.css b/static/css/styles.css index 18cf177..e2f91dd 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -615,3 +615,92 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .room-user > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chat-message a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; overflow-wrap: anywhere; } .chat-message a:hover { filter: brightness(1.15); } + +/* Sharing panel */ +.share-panel { display: grid; gap: 18px; } +.share-panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } +.share-panel-head h3, .share-section h4 { margin:0; } +.share-panel-head p { margin:4px 0 0; color:var(--muted); } +.share-section { display:grid; gap:10px; padding:14px; border:1px solid var(--border); border-radius:10px; background:var(--surface); } +.share-form-grid { display:grid; grid-template-columns:minmax(220px,1fr) minmax(140px,auto) auto; gap:10px; align-items:end; } +.share-form-grid label { display:grid; gap:5px; } +.share-link-form { grid-template-columns:minmax(140px,auto) minmax(110px,140px) auto auto; } +.share-forever { display:flex !important; align-items:center; gap:7px !important; min-height:38px; white-space:nowrap; } +.share-forever input { width:auto; } +.share-list { display:grid; gap:8px; } +.share-list-row { display:grid; grid-template-columns:minmax(180px,1fr) auto auto; gap:8px; align-items:center; padding:9px 0; border-top:1px solid var(--border); } +.share-list-row:first-child { border-top:0; } +.share-list-row strong, .share-list-row small { display:block; overflow-wrap:anywhere; } +.share-list-row small { color:var(--muted); margin-top:2px; } +.share-role { color:var(--muted); font-size:.82rem; } +.share-link-row { grid-template-columns:minmax(170px,1fr) minmax(130px,auto) 100px auto auto auto; } +.share-link-row input, .share-link-row select { min-width:0; } +.share-empty { margin:0; color:var(--muted); } +@media (max-width:800px) { + .share-form-grid, .share-link-form, .share-list-row, .share-link-row { grid-template-columns:1fr; align-items:stretch; } + .share-panel-head { flex-direction:column; } + .share-panel-head > button { align-self:flex-end; } +} + +/* Share dialog */ +.share-dialog { width: min(900px, calc(100% - 28px)); max-width: 900px; max-height: min(88dvh, 860px); padding: 0; overflow: hidden; } +.share-dialog::backdrop { background: rgb(0 0 0 / .68); backdrop-filter: blur(3px); } +.share-dialog .share-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; max-height: min(88dvh, 860px); gap: 0; } +.share-dialog .share-panel-head { padding: 20px 22px 16px; border-bottom: 1px solid var(--border); background: var(--surface); } +.share-dialog .share-panel-head strong { color: var(--text); } +.share-dialog-body { display: grid; gap: 16px; padding: 18px 22px 22px; overflow-y: auto; background: color-mix(in srgb, var(--surface) 92%, black); } +.share-dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 64px; padding: 12px 22px; border-top: 1px solid var(--border); background: var(--surface); } +.share-dialog-footer .form-message { margin: 0; } +.dialog-close { width: 34px; height: 34px; flex: 0 0 auto; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); color: var(--muted); font-size: 1.35rem; line-height: 1; cursor: pointer; } +.dialog-close:hover { color: var(--text); border-color: var(--border-strong); } +.share-section { padding: 16px; background: var(--surface); } +.share-section-head h4 { margin: 0; } +.share-section-head p { margin: 4px 0 2px; color: var(--muted); font-size: .82rem; } +.share-form-grid label > span:not(.sr-only), .share-link-row label > span:not(.sr-only) { color: var(--muted); font-size: .76rem; } +.share-dialog select { width: 100%; min-height: 42px; padding: 0 38px 0 12px; border: 1px solid var(--border-strong); border-radius: 8px; background-color: #10141a; color: var(--text); appearance: none; background-image: linear-gradient(45deg, transparent 50%, #9aa3b2 50%), linear-gradient(135deg, #9aa3b2 50%, transparent 50%); background-position: calc(100% - 16px) 17px, calc(100% - 11px) 17px; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; cursor: pointer; } +.share-dialog select:hover { border-color: #596273; } +.share-dialog select:focus { border-color: #7567db; box-shadow: 0 0 0 3px rgb(117 103 219 / .18); outline: none; } +.share-dialog input[type="text"], .share-dialog input[type="number"] { min-height: 42px; border-radius: 8px; padding: 0 12px; } +.share-hours-field { display: grid; grid-template-columns: minmax(70px, 1fr) auto; align-items: center; min-height: 42px; border: 1px solid var(--border-strong); border-radius: 8px; background: #10141a; overflow: hidden; } +.share-hours-field:focus-within { border-color: #7567db; box-shadow: 0 0 0 3px rgb(117 103 219 / .18); } +.share-hours-field input { width: 100%; min-width: 0; border: 0 !important; background: transparent; box-shadow: none !important; outline: 0 !important; } +.share-hours-field span { padding: 0 11px; color: var(--muted); font-size: .76rem; } +.share-list-row { min-height: 58px; padding: 10px 0; } +.share-list-identity { display: flex; align-items: center; gap: 10px; min-width: 0; } +.share-avatar { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; background: color-mix(in srgb, var(--accent) 20%, var(--surface-2)); color: #d8d3ff; font-weight: 700; } +.share-list-identity > div, .share-link-info { min-width: 0; } +.share-row-actions { display: flex; gap: 7px; justify-content: flex-end; } +.compact-button { min-height: 36px; padding: 7px 11px; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +@media (max-width: 800px) { + .share-dialog { width: calc(100% - 16px); max-height: calc(100dvh - 16px); } + .share-dialog .share-panel { max-height: calc(100dvh - 16px); } + .share-dialog .share-panel-head, .share-dialog-body, .share-dialog-footer { padding-left: 14px; padding-right: 14px; } + .share-dialog-footer { align-items: stretch; flex-direction: column; } + .share-dialog-footer > button { align-self: flex-end; } + .share-row-actions { justify-content: stretch; } + .share-row-actions button { flex: 1; } +} + +/* Refined sharing dialog and shared-resource attribution. */ +.share-dialog { + border: 1px solid var(--border-strong); + border-radius: 18px; + background: var(--surface); + box-shadow: 0 28px 90px rgb(0 0 0 / .58); +} +.share-dialog .share-panel { overflow: hidden; border-radius: inherit; } +.share-dialog .share-panel-head { border-radius: 18px 18px 0 0; } +.share-dialog-footer { border-radius: 0 0 18px 18px; } +.share-dialog-body { scrollbar-gutter: stable; } +.share-section { border-radius: 12px; box-shadow: inset 0 1px 0 rgb(255 255 255 / .025); } +.resource-copy { min-width: 0; } +.resource-title-line { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-width: 0; } +.resource-title-line a { min-width: 0; } +.resource-shared-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 3px 8px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: color-mix(in srgb, var(--accent) 70%, white); font-size: .68rem; font-weight: 650; line-height: 1.2; } +.resource-row--shared { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); background: color-mix(in srgb, var(--accent) 4%, transparent); } +@media (max-width: 640px) { + .share-dialog, .share-dialog .share-panel, .share-dialog .share-panel-head, .share-dialog-footer { border-radius: 14px; } + .share-dialog .share-panel-head { border-radius: 14px 14px 0 0; } + .share-dialog-footer { border-radius: 0 0 14px 14px; } +} diff --git a/static/js/api.js b/static/js/api.js index 854160a..16edb53 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -16,6 +16,7 @@ export async function api(path, options = {}) { 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." }; const requestError = new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`); + requestError.status = response.status; logWarn("api.failed", { method: options.method || "GET", path, status: response.status, message: requestError.message }); throw requestError; } diff --git a/static/js/home.js b/static/js/home.js index eaabd88..b9dc6ef 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -94,6 +94,9 @@ const resourcesList = document.querySelector("#resources-list"); const resourcesError = document.querySelector("#resources-error"); function authHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } +function escapeHtml(value) { const node=document.createElement("div"); node.textContent=String(value ?? ""); return node.innerHTML; } +function shareExpiry(hours, forever) { if (forever) return null; const value=Number(hours); if (!Number.isFinite(value) || value <= 0 || value > 87600) throw new Error("Enter a validity between 1 and 87600 hours."); return new Date(Date.now()+value*3600000).toISOString(); } +function formatShareExpiry(value) { if (!value) return "Never expires"; const date=new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; } async function loadResources() { resourcesError.textContent = ""; resourcesList.innerHTML = "

Loading…

"; try { @@ -103,7 +106,10 @@ async function loadResources() { for (const item of items) { const row = document.createElement("article"); row.className = "resource-row"; - row.innerHTML = `
${item.title}${item.kind === "workspace" ? "Workspace" : "Note"}${item.protected ? " · password protected" : ""}
`; + const sharedLabel = !item.owned ? `Shared by ${escapeHtml(item.shared_by || "another user")}` : ""; + const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only"; + row.classList.toggle("resource-row--shared", !Boolean(item.owned)); + row.innerHTML = `
${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}
${item.owned ? `` : ""}
`; const inline = row.querySelector("[data-inline]"); const closeInline = () => { inline.hidden = true; inline.innerHTML = ""; }; @@ -114,7 +120,65 @@ async function loadResources() { message.textContent = text; }; - row.querySelector("[data-password]").addEventListener("click", () => { + row.querySelector("[data-privacy]")?.addEventListener("click", async () => { + try { await api("/api/auth/resources/privacy", {method:"POST",headers:authHeaders(),body:JSON.stringify({kind:item.kind,slug:item.slug,private:!Boolean(item.private)})}); await loadResources(); } + catch(e){ resourcesError.textContent=e.message; } + }); + row.querySelector("[data-share]")?.addEventListener("click", async () => { + const dialog = document.createElement("dialog"); + dialog.className = "app-dialog share-dialog"; + dialog.innerHTML = ``; + document.body.append(dialog); + const closeDialog=()=>dialog.close(); + dialog.addEventListener("close",()=>dialog.remove(),{once:true}); + dialog.addEventListener("click",event=>{if(event.target===dialog) closeDialog();}); + dialog.querySelector("[data-cancel]").addEventListener("click",closeDialog); + dialog.querySelector("[data-done]").addEventListener("click",closeDialog); + const setDialogMessage = (text, type = "") => { + const message = dialog.querySelector("[data-inline-message]"); + message.className = `form-message resource-inline-message ${type}`.trim(); + message.textContent = text; + }; + const userForm=dialog.querySelector("[data-user-share-form]"); + const linkForm=dialog.querySelector("[data-link-form]"); + const userList=dialog.querySelector("[data-user-list]"); + const linkList=dialog.querySelector("[data-link-list]"); + const syncForever=()=>{linkForm.hours.disabled=linkForm.forever.checked;}; + linkForm.forever.addEventListener("change",syncForever); syncForever(); + const refresh=async()=>{ + const d=await api(`/api/auth/resources/sharing?kind=${encodeURIComponent(item.kind)}&slug=${encodeURIComponent(item.slug)}`,{headers:authHeaders()}); + userList.innerHTML=d.users.length?d.users.map(u=>``).join(""):''; + linkList.innerHTML=d.links.length?d.links.map(link=>``).join(""):''; + userList.querySelectorAll("[data-remove-user]").forEach(button=>button.addEventListener("click",async()=>{try{button.disabled=true;await api("/api/auth/resources/sharing",{method:"DELETE",headers:authHeaders(),body:JSON.stringify({kind:item.kind,slug:item.slug,email:button.dataset.removeUser})});setDialogMessage("Access removed.","success");await refresh();}catch(err){setDialogMessage(err.message,"error");button.disabled=false;}})); + linkList.querySelectorAll("[data-link-token]").forEach(linkRow=>{ + const forever=linkRow.elements.forever, hours=linkRow.elements.hours; const sync=()=>{hours.disabled=forever.checked;}; forever.addEventListener("change",sync); sync(); + linkRow.addEventListener("submit",async event=>{event.preventDefault();try{const expires_at=shareExpiry(hours.value,forever.checked);await api("/api/auth/resources/share-links",{method:"PUT",headers:authHeaders(),body:JSON.stringify({kind:item.kind,slug:item.slug,token:linkRow.dataset.linkToken,permission:linkRow.elements.permission.value,expires_at})});setDialogMessage("Link updated.","success");await refresh();}catch(err){setDialogMessage(err.message,"error");}}); + linkRow.querySelector("[data-revoke-link]").addEventListener("click",async()=>{try{await api("/api/auth/resources/share-links",{method:"DELETE",headers:authHeaders(),body:JSON.stringify({kind:item.kind,slug:item.slug,token:linkRow.dataset.linkToken})});setDialogMessage("Link revoked.","success");await refresh();}catch(err){setDialogMessage(err.message,"error");}}); + }); + }; + userForm.addEventListener("submit",async event=>{event.preventDefault();try{await api("/api/auth/resources/sharing",{method:"POST",headers:authHeaders(),body:JSON.stringify({kind:item.kind,slug:item.slug,emails:userForm.emails.value,permission:userForm.permission.value})});userForm.emails.value="";setDialogMessage("Access granted.","success");await refresh();}catch(err){setDialogMessage(err.message,"error");}}); + linkForm.addEventListener("submit",async event=>{event.preventDefault();try{const expires_at=shareExpiry(linkForm.hours.value,linkForm.forever.checked);const result=await api("/api/auth/resources/share-links",{method:"POST",headers:authHeaders(),body:JSON.stringify({kind:item.kind,slug:item.slug,permission:linkForm.permission.value,expires_at})});const absolute=new URL(result.url,location.origin).href;await navigator.clipboard.writeText(absolute);setDialogMessage("Link created and copied.","success");await refresh();}catch(err){setDialogMessage(err.message,"error");}}); + dialog.showModal(); + try { await refresh(); } catch(err) { setDialogMessage(err.message,"error"); } + }); + + row.querySelector("[data-password]")?.addEventListener("click", () => { inline.hidden = false; inline.innerHTML = `

Leave empty to remove password protection.

`; const form = inline.querySelector("form"); @@ -138,7 +202,7 @@ async function loadResources() { input.focus(); }); - row.querySelector("[data-delete]").addEventListener("click", () => { + row.querySelector("[data-delete]")?.addEventListener("click", () => { inline.hidden = false; inline.innerHTML = `

Delete “${item.title}” permanently?

`; inline.querySelector("[data-cancel]").addEventListener("click", closeInline); diff --git a/static/js/note.js b/static/js/note.js index f619d16..7dc33b8 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -18,7 +18,8 @@ const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document const roomDetails=document.querySelector("#room-details"), roomUsers=document.querySelector("#room-users"), roomCount=document.querySelector("#room-count"), socketLatency=document.querySelector("#socket-latency"), chatMessages=document.querySelector("#chat-messages"), chatForm=document.querySelector("#chat-form"), chatInput=document.querySelector("#chat-input"), chatUnread=document.querySelector("#chat-unread"); let unreadChat=0; const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"), currentUser=document.querySelector("#current-user"), userColorPicker=document.querySelector("#user-color-picker"); -let accessToken=getAccessToken("workspace",workspaceSlug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; +const shareToken=new URLSearchParams(location.search).get("share");if(shareToken)setAccessToken("workspace",workspaceSlug,shareToken); +let accessToken=shareToken||getAuthToken()||getAccessToken("workspace",workspaceSlug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off"; fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono"; diff --git a/static/js/pad.js b/static/js/pad.js index 30e6a09..803c257 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -17,7 +17,8 @@ const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document const roomDetails=document.querySelector("#room-details"), roomUsers=document.querySelector("#room-users"), roomCount=document.querySelector("#room-count"), socketLatency=document.querySelector("#socket-latency"), chatMessages=document.querySelector("#chat-messages"), chatForm=document.querySelector("#chat-form"), chatInput=document.querySelector("#chat-input"), chatUnread=document.querySelector("#chat-unread"); let unreadChat=0; const compactToggle=document.querySelector("#compact-toggle"), publicTaskUpdates=document.querySelector("#public-task-updates"), fontFamily=document.querySelector("#font-family"), fontSize=document.querySelector("#font-size"), currentUser=document.querySelector("#current-user"), userColorPicker=document.querySelector("#user-color-picker"); -let accessToken=getAccessToken("pad",slug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; +const shareToken=new URLSearchParams(location.search).get("share");if(shareToken)setAccessToken("pad",slug,shareToken); +let accessToken=shareToken||getAuthToken()||getAccessToken("pad",slug), password="", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; compactToggle.checked=localStorage.getItem("rustpad:compact")!=="off"; fontFamily.value=localStorage.getItem("rustpad:font-family")||"mono"; diff --git a/static/js/workspace.js b/static/js/workspace.js index dde2e0b..8daba8a 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -3,13 +3,15 @@ installGlobalDiagnostics(); import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; -import { getNickname, getAccessToken, setAccessToken } from "@rustpad/session"; +import { getNickname, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session"; import { askConfirm } from "./modal.js"; const parts = location.pathname.split("/").filter(Boolean); const slug = parts[1]; let info; -let accessToken = getAccessToken("workspace", slug); +const shareToken=new URLSearchParams(location.search).get("share"); +let accessToken=shareToken||getAuthToken()||getAccessToken("workspace",slug); +if(shareToken)setAccessToken("workspace",slug,shareToken); const dialog = document.querySelector("#password-dialog"); const notesList = document.querySelector("#notes-list"); const notesViewKey = `rustpad:workspace:${slug}:notes-view`; @@ -89,6 +91,8 @@ async function openWorkspace() { if (info?.protected || e.message.toLowerCase().includes("password")) { document.querySelector("#password-error").textContent = e.message; if (!dialog.open) dialog.showModal(); + } else if (e.status === 403) { + location.replace("/errors/private-workspace"); } else document.querySelector("#workspace-error").textContent = e.message; } } @@ -98,7 +102,10 @@ async function init() { document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; if (info.protected && !accessToken) dialog.showModal(); else openWorkspace(); - } catch (e) { document.querySelector("#workspace-error").textContent = e.message; } + } catch (e) { + if (e.status === 403) location.replace("/errors/private-workspace"); + else document.querySelector("#workspace-error").textContent = e.message; + } } document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault();