share funxtions

This commit is contained in:
Mateusz Gruszczyński
2026-07-23 15:07:59 +02:00
parent dc6227896b
commit 8c3842157d
18 changed files with 409 additions and 29 deletions
-1
View File
@@ -58,4 +58,3 @@ SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM="RustPad <no-reply@example.com>"
Generated
+1 -1
View File
@@ -1408,7 +1408,7 @@ dependencies = [
[[package]]
name = "rustpad"
version = "0.0.8"
version = "0.0.9"
dependencies = [
"argon2",
"axum",
+1 -1
View File
@@ -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"
+24
View File
@@ -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);
@@ -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);
@@ -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);
+9 -6
View File
@@ -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)
+17
View File
@@ -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<SharedState>) -> Response {
error_response(
StatusCode::FORBIDDEN,
"403",
"Private workspace",
"You do not have permission to access this private workspace. Ask the owner to share it with your account or use a valid share link.",
"/",
"Home page",
&state.asset_version,
)
}
async fn health() -> &'static str {
"ok"
}
+122 -3
View File
@@ -37,8 +37,18 @@ pub struct User {
kind: String, slug: String, #[serde(default)] password: Option<String>
}
#[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<String> }
#[derive(Deserialize)] pub struct UpdateShareLinkRequest { kind: String, slug: String, token: String, permission: String, expires_at: Option<String> }
#[derive(Deserialize)] pub struct RevokeShareLinkRequest { kind: String, slug: String, token: String }
#[derive(Serialize)] pub struct ResourceList {
workspaces: Vec<ResourceItem>, pads: Vec<ResourceItem>
}
@@ -161,8 +171,8 @@ pub async fn me(State(state): State<SharedState>, headers: HeaderMap) -> Result<
pub async fn resources(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<ResourceList>, 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<SharedState>, headers: HeaderMap, Json(req): Json<PrivacyRequest>) -> Result<Json<serde_json::Value>, 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<SharedState>, headers: HeaderMap, Json(req): Json<ShareUsersRequest>) -> Result<Json<serde_json::Value>, 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<String> = 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<SharedState>, headers: HeaderMap, Json(req): Json<RemoveShareRequest>) -> Result<Json<serde_json::Value>, 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<SharedState>, headers: HeaderMap, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String,String>>) -> Result<Json<serde_json::Value>, 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>,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::<Vec<_>>(), "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::<Vec<_>>() })))
}
pub async fn create_share_link(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<CreateShareLinkRequest>) -> Result<Json<serde_json::Value>, 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<SharedState>, headers: HeaderMap, Json(req): Json<UpdateShareLinkRequest>) -> Result<Json<serde_json::Value>, 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<SharedState>, headers: HeaderMap, Json(req): Json<RevokeShareLinkRequest>) -> Result<Json<serde_json::Value>, 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<Option<String>, 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<String> = 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<String> = 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<SharedState>, headers: HeaderMap) -> Result<Json<serde_json::Value>, 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)?;
+2
View File
@@ -30,6 +30,7 @@ pub struct Workspace {
pub password_hash: Option<String>,
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<Option<Pad>, sqlx::Error> {
+4 -4
View File
@@ -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 (?, ?, ?, ?)";
+14 -5
View File
@@ -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, &note_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::<ClientMessage>(&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::<ClientMessage>(&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{
+89
View File
@@ -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; }
}
+1
View File
@@ -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;
}
+67 -3
View File
@@ -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 = "<p>Loading…</p>";
try {
@@ -103,7 +106,10 @@ async function loadResources() {
for (const item of items) {
const row = document.createElement("article");
row.className = "resource-row";
row.innerHTML = `<div class="resource-main"><div><a href="${item.url}">${item.title}</a><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.protected ? " · password protected" : ""}</small></div><div class="resource-actions"><button type="button" data-password>Change password</button><button type="button" data-delete>Delete</button></div></div><div class="resource-inline" data-inline hidden></div>`;
const sharedLabel = !item.owned ? `<span class="resource-shared-badge">Shared by ${escapeHtml(item.shared_by || "another user")}</span>` : "";
const permissionLabel = item.permission === "rw" ? "Read and write" : "Read only";
row.classList.toggle("resource-row--shared", !Boolean(item.owned));
row.innerHTML = `<div class="resource-main"><div class="resource-copy"><div class="resource-title-line"><a href="${item.url}">${escapeHtml(item.title)}</a>${sharedLabel}</div><small>${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}</small></div><div class="resource-actions">${item.owned ? `<button type="button" data-privacy>${item.private ? "Make public" : "Make private"}</button><button type="button" data-share>Share</button><button type="button" data-password>Change password</button><button type="button" data-delete>Delete</button>` : ""}</div></div><div class="resource-inline" data-inline hidden></div>`;
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 = `<div class="share-panel">
<div class="share-panel-head"><div><h3>Share access</h3><p>Manage registered users and direct access links for <strong>${escapeHtml(item.title)}</strong>.</p></div><button class="dialog-close" type="button" data-cancel aria-label="Close share dialog">×</button></div>
<div class="share-dialog-body">
<section class="share-section"><div class="share-section-head"><div><h4>People with access</h4><p>Invite existing Rustpad users by e-mail.</p></div></div><form data-user-share-form class="share-form-grid">
<label class="share-emails"><span>E-mail addresses</span><input name="emails" type="text" placeholder="alice@example.com, bob@example.com" autocomplete="off"></label>
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
<button class="primary-button" type="submit">Grant access</button>
</form><div data-user-list class="share-list"></div></section>
<section class="share-section"><div class="share-section-head"><div><h4>Direct links</h4><p>Create links for people without an account or manage existing links.</p></div></div><form data-link-form class="share-form-grid share-link-form">
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></label>
<label><span>Valid for</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" inputmode="numeric"><span>hours</span></div></label>
<label class="share-forever"><input name="forever" type="checkbox"><span>Never expires</span></label>
<button class="primary-button" type="submit">Create link</button>
</form><div data-link-list class="share-list"></div></section>
</div>
<div class="share-dialog-footer"><p class="form-message resource-inline-message" data-inline-message role="status"></p><button class="secondary-button" type="button" data-done>Done</button></div>
</div>`;
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=>`<div class="share-list-row"><div class="share-list-identity"><span class="share-avatar">${escapeHtml((u.nickname || u.email || "?").slice(0,1).toUpperCase())}</span><div><strong>${escapeHtml(u.nickname)}</strong><small>${escapeHtml(u.email)}</small></div></div><span class="share-role">${u.permission === "rw" ? "Read and write" : "Read only"}</span><button class="secondary-button compact-button" type="button" data-remove-user="${escapeHtml(u.email)}">Remove</button></div>`).join(""):'<p class="share-empty">No users have access.</p>';
linkList.innerHTML=d.links.length?d.links.map(link=>`<form class="share-list-row share-link-row" data-link-token="${escapeHtml(link.token)}"><div class="share-link-info"><strong>Link ${escapeHtml(link.token.slice(0,8))}…</strong><small>${escapeHtml(formatShareExpiry(link.expires_at))}</small></div><label><span class="sr-only">Permission</span><select name="permission" aria-label="Link permission"><option value="ro" ${link.permission==="ro"?"selected":""}>Read only</option><option value="rw" ${link.permission==="rw"?"selected":""}>Read and write</option></select></label><label><span class="sr-only">Validity in hours</span><div class="share-hours-field"><input name="hours" type="number" min="1" max="87600" value="24" aria-label="New validity in hours"><span>h</span></div></label><label class="share-forever"><input name="forever" type="checkbox" ${link.expires_at?"":"checked"}><span>Never</span></label><div class="share-row-actions"><button class="secondary-button compact-button" type="submit">Update</button><button class="danger-button compact-button" type="button" data-revoke-link>Revoke</button></div></form>`).join(""):'<p class="share-empty">No active links.</p>';
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 = `<form class="resource-password-form" autocomplete="off"><label>New password<input name="resource-password" type="password" minlength="8" maxlength="128" autocomplete="new-password" data-bwignore="true" placeholder="Minimum 8 characters"></label><p class="resource-inline-help">Leave empty to remove password protection.</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="primary-button" type="submit">Save</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></form>`;
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 = `<div class="resource-delete-confirm"><p>Delete “${item.title}” permanently?</p><p class="form-message resource-inline-message" data-inline-message role="status"></p><div class="resource-inline-actions"><button class="danger-button" type="button" data-confirm-delete>Delete</button><button class="secondary-button" type="button" data-cancel>Cancel</button></div></div>`;
inline.querySelector("[data-cancel]").addEventListener("click", closeInline);
+2 -1
View File
@@ -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";
+2 -1
View File
@@ -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";
+10 -3
View File
@@ -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();