big update in share links
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.21"
|
version = "0.2.24"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustpad"
|
name = "rustpad"
|
||||||
version = "0.2.23"
|
version = "0.2.24"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.94"
|
rust-version = "1.94"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
ALTER TABLE resource_share_links ADD COLUMN token TEXT NULL;
|
-- Legacy migration retained for numbering only. Plaintext share tokens are not stored.
|
||||||
|
SELECT 1;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
UPDATE resource_share_links SET token = NULL;
|
|
||||||
CREATE TABLE resource_share_sessions (
|
CREATE TABLE resource_share_sessions (
|
||||||
session_token_hash VARCHAR(64) PRIMARY KEY,
|
session_token_hash VARCHAR(64) PRIMARY KEY,
|
||||||
share_token_hash VARCHAR(64) NOT NULL,
|
share_token_hash VARCHAR(64) NOT NULL,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE resource_share_links ADD COLUMN label VARCHAR(120) NULL;
|
||||||
@@ -1 +1,2 @@
|
|||||||
ALTER TABLE resource_share_links ADD COLUMN token TEXT;
|
-- Legacy migration retained for numbering only. Plaintext share tokens are not stored.
|
||||||
|
SELECT 1;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
UPDATE resource_share_links SET token = NULL;
|
|
||||||
CREATE TABLE resource_share_sessions (
|
CREATE TABLE resource_share_sessions (
|
||||||
session_token_hash TEXT PRIMARY KEY,
|
session_token_hash TEXT PRIMARY KEY,
|
||||||
share_token_hash TEXT NOT NULL REFERENCES resource_share_links(token_hash) ON DELETE CASCADE,
|
share_token_hash TEXT NOT NULL REFERENCES resource_share_links(token_hash) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE resource_share_links ADD COLUMN label TEXT;
|
||||||
@@ -1 +1,2 @@
|
|||||||
ALTER TABLE resource_share_links ADD COLUMN token TEXT;
|
-- Legacy migration retained for numbering only. Plaintext share tokens are not stored.
|
||||||
|
SELECT 1;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
UPDATE resource_share_links SET token = NULL;
|
|
||||||
CREATE TABLE resource_share_sessions (
|
CREATE TABLE resource_share_sessions (
|
||||||
session_token_hash TEXT PRIMARY KEY,
|
session_token_hash TEXT PRIMARY KEY,
|
||||||
share_token_hash TEXT NOT NULL REFERENCES resource_share_links(token_hash) ON DELETE CASCADE,
|
share_token_hash TEXT NOT NULL REFERENCES resource_share_links(token_hash) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE resource_share_links ADD COLUMN label TEXT;
|
||||||
+16
-20
@@ -96,25 +96,6 @@ pub async fn create_resource_access_token(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn verify_resource_access_token(
|
|
||||||
state: &SharedState,
|
|
||||||
kind: &str,
|
|
||||||
slug: &str,
|
|
||||||
token: Option<&str>,
|
|
||||||
) -> Result<bool, ApiError> {
|
|
||||||
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
if crate::auth::share_access_permission(state, kind, slug, Some(token))
|
|
||||||
.await
|
|
||||||
.map_err(|error| ApiError::forbidden(&error.message))?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
verify_password_access_token(state, kind, slug, Some(token)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn verify_password_access_token(
|
pub(crate) async fn verify_password_access_token(
|
||||||
state: &SharedState,
|
state: &SharedState,
|
||||||
kind: &str,
|
kind: &str,
|
||||||
@@ -134,7 +115,22 @@ pub(crate) async fn verify_password_access_token(
|
|||||||
.bind(Utc::now().to_rfc3339())
|
.bind(Utc::now().to_rfc3339())
|
||||||
.fetch_one(state.db.pool())
|
.fetch_one(state.db.pool())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(count > 0)
|
if count == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password-derived access must stop working when the resource no longer
|
||||||
|
// has a password. This also invalidates tokens created by older versions
|
||||||
|
// for private resources that never had a password configured.
|
||||||
|
match kind {
|
||||||
|
"workspace" => Ok(db::find_workspace(&state.db, slug)
|
||||||
|
.await?
|
||||||
|
.is_some_and(|workspace| workspace.password_hash.is_some())),
|
||||||
|
"pad" => Ok(db::find_pad(&state.db, slug)
|
||||||
|
.await?
|
||||||
|
.is_some_and(|pad| pad.password_hash.is_some())),
|
||||||
|
_ => Ok(false),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn hash_access_token(token: &str) -> String {
|
pub(super) fn hash_access_token(token: &str) -> String {
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ async fn share_session_redirect(
|
|||||||
kind,
|
kind,
|
||||||
slug,
|
slug,
|
||||||
share,
|
share,
|
||||||
crate::security::share_session_token(headers, kind, slug),
|
|
||||||
&client_key,
|
&client_key,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+91
-28
@@ -40,6 +40,7 @@ use crate::{
|
|||||||
const MIN_PASSWORD: usize = 8;
|
const MIN_PASSWORD: usize = 8;
|
||||||
const MAX_PASSWORD: usize = 128;
|
const MAX_PASSWORD: usize = 128;
|
||||||
const MAX_NICKNAME: usize = 40;
|
const MAX_NICKNAME: usize = 40;
|
||||||
|
const MAX_SHARE_LINK_LABEL: usize = 120;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
@@ -149,6 +150,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct SharingLinkRow {
|
struct SharingLinkRow {
|
||||||
token_hash: String,
|
token_hash: String,
|
||||||
|
label: Option<String>,
|
||||||
permission: String,
|
permission: String,
|
||||||
expires_at: Option<String>,
|
expires_at: Option<String>,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
@@ -158,9 +160,10 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingLinkRow {
|
|||||||
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
token_hash: crate::row_decode::text(row, 0)?,
|
token_hash: crate::row_decode::text(row, 0)?,
|
||||||
permission: crate::row_decode::text(row, 1)?,
|
label: crate::row_decode::optional_text(row, 1)?,
|
||||||
expires_at: crate::row_decode::optional_text(row, 2)?,
|
permission: crate::row_decode::text(row, 2)?,
|
||||||
created_at: crate::row_decode::text(row, 3)?,
|
expires_at: crate::row_decode::optional_text(row, 3)?,
|
||||||
|
created_at: crate::row_decode::text(row, 4)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,6 +296,8 @@ pub struct RemoveShareRequest {
|
|||||||
pub struct CreateShareLinkRequest {
|
pub struct CreateShareLinkRequest {
|
||||||
kind: String,
|
kind: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
|
#[serde(default)]
|
||||||
|
label: Option<String>,
|
||||||
permission: String,
|
permission: String,
|
||||||
expires_at: Option<String>,
|
expires_at: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -300,7 +305,9 @@ pub struct CreateShareLinkRequest {
|
|||||||
pub struct UpdateShareLinkRequest {
|
pub struct UpdateShareLinkRequest {
|
||||||
kind: String,
|
kind: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
token: String,
|
token_hash: String,
|
||||||
|
#[serde(default)]
|
||||||
|
label: Option<String>,
|
||||||
permission: String,
|
permission: String,
|
||||||
expires_at: Option<String>,
|
expires_at: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -308,7 +315,7 @@ pub struct UpdateShareLinkRequest {
|
|||||||
pub struct RevokeShareLinkRequest {
|
pub struct RevokeShareLinkRequest {
|
||||||
kind: String,
|
kind: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
token: String,
|
token_hash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -361,8 +368,6 @@ pub struct IdentityResponse {
|
|||||||
}
|
}
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct RegisterResponse {
|
pub struct RegisterResponse {
|
||||||
#[serde(skip_serializing)]
|
|
||||||
token: Option<String>,
|
|
||||||
nickname: String,
|
nickname: String,
|
||||||
email: String,
|
email: String,
|
||||||
expires_at: Option<String>,
|
expires_at: Option<String>,
|
||||||
@@ -514,7 +519,6 @@ pub async fn register(
|
|||||||
return Ok((
|
return Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(RegisterResponse {
|
Json(RegisterResponse {
|
||||||
token: None,
|
|
||||||
nickname: user.nickname,
|
nickname: user.nickname,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
expires_at: None,
|
expires_at: None,
|
||||||
@@ -534,7 +538,6 @@ pub async fn register(
|
|||||||
let mut response = (
|
let mut response = (
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(RegisterResponse {
|
Json(RegisterResponse {
|
||||||
token: Some(session.token),
|
|
||||||
nickname: session.nickname,
|
nickname: session.nickname,
|
||||||
email: session.email,
|
email: session.email,
|
||||||
expires_at: Some(session.expires_at),
|
expires_at: Some(session.expires_at),
|
||||||
@@ -1441,6 +1444,40 @@ async fn ensure_owner(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn resource_is_public_unprotected(
|
||||||
|
state: &SharedState,
|
||||||
|
kind: &str,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<bool, AuthError> {
|
||||||
|
let slug = slug.trim();
|
||||||
|
match kind {
|
||||||
|
"workspace" => Ok(crate::db::find_workspace(&state.db, slug)
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?
|
||||||
|
.is_some_and(|workspace| {
|
||||||
|
workspace.is_private == 0 && workspace.password_hash.is_none()
|
||||||
|
})),
|
||||||
|
"pad" => Ok(crate::db::find_pad(&state.db, slug)
|
||||||
|
.await
|
||||||
|
.map_err(AuthError::database)?
|
||||||
|
.is_some_and(|pad| pad.is_private == 0 && pad.password_hash.is_none())),
|
||||||
|
_ => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_share_links_enabled(
|
||||||
|
state: &SharedState,
|
||||||
|
kind: &str,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<(), AuthError> {
|
||||||
|
if resource_is_public_unprotected(state, kind, slug).await? {
|
||||||
|
return Err(AuthError::conflict(
|
||||||
|
"Direct share links are disabled for public resources without a password.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_resource_privacy(
|
pub async fn set_resource_privacy(
|
||||||
State(state): State<SharedState>,
|
State(state): State<SharedState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -1706,6 +1743,7 @@ pub async fn resource_sharing(
|
|||||||
.get("slug")
|
.get("slug")
|
||||||
.ok_or_else(|| AuthError::bad_request("Missing slug."))?;
|
.ok_or_else(|| AuthError::bad_request("Missing slug."))?;
|
||||||
ensure_owner(&state, owner.id, kind, slug).await?;
|
ensure_owner(&state, owner.id, kind, slug).await?;
|
||||||
|
let share_links_enabled = !resource_is_public_unprotected(&state, kind, slug).await?;
|
||||||
let users: Vec<SharingUserRow> = sqlx::query_as(queries::get(
|
let users: Vec<SharingUserRow> = sqlx::query_as(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::RESOURCE_SHARING_USERS,
|
queries::RESOURCE_SHARING_USERS,
|
||||||
@@ -1715,7 +1753,8 @@ pub async fn resource_sharing(
|
|||||||
.fetch_all(state.db.pool())
|
.fetch_all(state.db.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
let links: Vec<SharingLinkRow> = sqlx::query_as(queries::get(
|
let links: Vec<SharingLinkRow> = if share_links_enabled {
|
||||||
|
sqlx::query_as(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::RESOURCE_SHARING_LINKS,
|
queries::RESOURCE_SHARING_LINKS,
|
||||||
))
|
))
|
||||||
@@ -1723,7 +1762,10 @@ pub async fn resource_sharing(
|
|||||||
.bind(slug)
|
.bind(slug)
|
||||||
.fetch_all(state.db.pool())
|
.fetch_all(state.db.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
let pending: Vec<PendingShareRow> = sqlx::query_as(queries::get(
|
let pending: Vec<PendingShareRow> = sqlx::query_as(queries::get(
|
||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::RESOURCE_SHARING_PENDING,
|
queries::RESOURCE_SHARING_PENDING,
|
||||||
@@ -1734,7 +1776,7 @@ pub async fn resource_sharing(
|
|||||||
.await
|
.await
|
||||||
.map_err(AuthError::database)?;
|
.map_err(AuthError::database)?;
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>() }),
|
serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::<Vec<_>>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::<Vec<_>>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"label":row.label,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>(), "share_links_enabled":share_links_enabled }),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1745,12 +1787,15 @@ pub async fn create_share_link(
|
|||||||
) -> Result<Response, AuthError> {
|
) -> Result<Response, AuthError> {
|
||||||
let owner = require_user(&state, &headers).await?;
|
let owner = require_user(&state, &headers).await?;
|
||||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||||
|
ensure_share_links_enabled(&state, &req.kind, &req.slug).await?;
|
||||||
let permission = validate_permission(&req.permission)?;
|
let permission = validate_permission(&req.permission)?;
|
||||||
|
let label = normalize_share_link_label(req.label.as_deref())?;
|
||||||
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
|
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
|
||||||
let token = random_token();
|
let token = random_token();
|
||||||
let token_hash = hash_token(&token);
|
let token_hash = hash_token(&token);
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
|
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
|
||||||
.bind(token_hash)
|
.bind(&token_hash)
|
||||||
|
.bind(&label)
|
||||||
.bind(&req.kind)
|
.bind(&req.kind)
|
||||||
.bind(req.slug.trim())
|
.bind(req.slug.trim())
|
||||||
.bind(permission)
|
.bind(permission)
|
||||||
@@ -1765,7 +1810,7 @@ pub async fn create_share_link(
|
|||||||
format!("/p/{}", req.slug.trim())
|
format!("/p/{}", req.slug.trim())
|
||||||
};
|
};
|
||||||
let mut response = Json(
|
let mut response = Json(
|
||||||
serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":expires_at}),
|
serde_json::json!({"token_hash":token_hash,"url":format!("{base}?share={token}"),"label":label,"permission":permission,"expires_at":expires_at}),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
@@ -1786,12 +1831,15 @@ pub async fn update_share_link(
|
|||||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||||
let owner = require_user(&state, &headers).await?;
|
let owner = require_user(&state, &headers).await?;
|
||||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||||
|
ensure_share_links_enabled(&state, &req.kind, &req.slug).await?;
|
||||||
let permission = validate_permission(&req.permission)?;
|
let permission = validate_permission(&req.permission)?;
|
||||||
|
let label = normalize_share_link_label(req.label.as_deref())?;
|
||||||
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
|
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
|
||||||
let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE))
|
let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE))
|
||||||
|
.bind(&label)
|
||||||
.bind(permission)
|
.bind(permission)
|
||||||
.bind(&expires_at)
|
.bind(&expires_at)
|
||||||
.bind(req.token.trim())
|
.bind(req.token_hash.trim())
|
||||||
.bind(&req.kind)
|
.bind(&req.kind)
|
||||||
.bind(req.slug.trim())
|
.bind(req.slug.trim())
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
@@ -1803,7 +1851,7 @@ pub async fn update_share_link(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
serde_json::json!({"ok":true,"permission":permission,"expires_at":expires_at}),
|
serde_json::json!({"ok":true,"label":label,"permission":permission,"expires_at":expires_at}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1814,9 +1862,10 @@ pub async fn revoke_share_link(
|
|||||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||||
let owner = require_user(&state, &headers).await?;
|
let owner = require_user(&state, &headers).await?;
|
||||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||||
|
ensure_share_links_enabled(&state, &req.kind, &req.slug).await?;
|
||||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE))
|
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE))
|
||||||
.bind(Utc::now().to_rfc3339())
|
.bind(Utc::now().to_rfc3339())
|
||||||
.bind(req.token.trim())
|
.bind(req.token_hash.trim())
|
||||||
.bind(&req.kind)
|
.bind(&req.kind)
|
||||||
.bind(req.slug.trim())
|
.bind(req.slug.trim())
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
@@ -1826,7 +1875,7 @@ pub async fn revoke_share_link(
|
|||||||
state.db.kind(),
|
state.db.kind(),
|
||||||
queries::SHARE_SESSIONS_DELETE_BY_LINK,
|
queries::SHARE_SESSIONS_DELETE_BY_LINK,
|
||||||
))
|
))
|
||||||
.bind(req.token.trim())
|
.bind(req.token_hash.trim())
|
||||||
.bind(&req.kind)
|
.bind(&req.kind)
|
||||||
.bind(req.slug.trim())
|
.bind(req.slug.trim())
|
||||||
.execute(state.db.pool())
|
.execute(state.db.pool())
|
||||||
@@ -1842,6 +1891,24 @@ fn validate_permission(value: &str) -> Result<&str, AuthError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_share_link_label(value: Option<&str>) -> Result<Option<String>, AuthError> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if value.chars().count() > MAX_SHARE_LINK_LABEL
|
||||||
|
|| value.chars().any(|character| character.is_control())
|
||||||
|
{
|
||||||
|
return Err(AuthError::bad_request(
|
||||||
|
"Link label must contain at most 120 printable characters.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(value.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_share_expiration(value: Option<&str>) -> Result<Option<String>, AuthError> {
|
fn normalize_share_expiration(value: Option<&str>) -> Result<Option<String>, AuthError> {
|
||||||
let Some(value) = value else {
|
let Some(value) = value else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -1903,9 +1970,11 @@ pub async fn create_share_session(
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
share_token: &str,
|
share_token: &str,
|
||||||
existing_session_token: Option<&str>,
|
|
||||||
client_key: &str,
|
client_key: &str,
|
||||||
) -> Result<Option<ShareSession>, AuthError> {
|
) -> Result<Option<ShareSession>, AuthError> {
|
||||||
|
if resource_is_public_unprotected(state, kind, slug).await? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let share_token = share_token.trim();
|
let share_token = share_token.trim();
|
||||||
if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") {
|
if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -1939,15 +2008,6 @@ pub async fn create_share_session(
|
|||||||
warn!(kind, slug, "invalid share link permission in database");
|
warn!(kind, slug, "invalid share link permission in database");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if source.permission == "ro"
|
|
||||||
&& share_session_permission(state, kind, slug, existing_session_token)
|
|
||||||
.await?
|
|
||||||
.as_deref()
|
|
||||||
== Some("rw")
|
|
||||||
{
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let session_limit = now + Duration::days(state.anonymous_access_token_ttl_days);
|
let session_limit = now + Duration::days(state.anonymous_access_token_ttl_days);
|
||||||
let expires_at = match source.expires_at.as_deref() {
|
let expires_at = match source.expires_at.as_deref() {
|
||||||
Some(value) => match chrono::DateTime::parse_from_rfc3339(value) {
|
Some(value) => match chrono::DateTime::parse_from_rfc3339(value) {
|
||||||
@@ -2079,6 +2139,9 @@ pub async fn share_access_permission(
|
|||||||
slug: &str,
|
slug: &str,
|
||||||
token: Option<&str>,
|
token: Option<&str>,
|
||||||
) -> Result<Option<String>, AuthError> {
|
) -> Result<Option<String>, AuthError> {
|
||||||
|
if resource_is_public_unprotected(state, kind, slug).await? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
let permission = share_session_permission(state, kind, slug, token).await?;
|
let permission = share_session_permission(state, kind, slug, token).await?;
|
||||||
if permission.is_some() {
|
if permission.is_some() {
|
||||||
return Ok(permission);
|
return Ok(permission);
|
||||||
|
|||||||
+56
-2
@@ -157,7 +157,7 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>)
|
|||||||
&workspace.password_hash,
|
&workspace.password_hash,
|
||||||
password.filter(|value| !value.is_empty()),
|
password.filter(|value| !value.is_empty()),
|
||||||
) {
|
) {
|
||||||
(None, _) => true,
|
(None, _) => false,
|
||||||
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|parsed| {
|
.and_then(|parsed| {
|
||||||
@@ -433,7 +433,7 @@ pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
|
|||||||
&pad.password_hash,
|
&pad.password_hash,
|
||||||
password.filter(|value| !value.is_empty()),
|
password.filter(|value| !value.is_empty()),
|
||||||
) {
|
) {
|
||||||
(None, _) => true,
|
(None, _) => false,
|
||||||
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|parsed| {
|
.and_then(|parsed| {
|
||||||
@@ -544,3 +544,57 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for Pad {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod password_verification_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn workspace(password_hash: Option<String>) -> Workspace {
|
||||||
|
Workspace {
|
||||||
|
id: 1,
|
||||||
|
slug: "private-workspace".into(),
|
||||||
|
title: "Private workspace".into(),
|
||||||
|
password_hash,
|
||||||
|
created_at: String::new(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
is_private: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pad(password_hash: Option<String>) -> Pad {
|
||||||
|
Pad {
|
||||||
|
id: 1,
|
||||||
|
slug: "private-pad".into(),
|
||||||
|
title: "Private pad".into(),
|
||||||
|
content: String::new(),
|
||||||
|
password_hash,
|
||||||
|
created_at: String::new(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
owner_map: "[]".into(),
|
||||||
|
is_private: 1,
|
||||||
|
created_by_guest_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_password_does_not_grant_password_access() {
|
||||||
|
assert!(!verify_workspace_password(&workspace(None), None));
|
||||||
|
assert!(!verify_workspace_password(&workspace(None), Some("anything")));
|
||||||
|
assert!(!verify_pad_password(&pad(None), None));
|
||||||
|
assert!(!verify_pad_password(&pad(None), Some("anything")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn configured_password_is_verified() {
|
||||||
|
let workspace = workspace(Some(hash_password("workspace-secret")));
|
||||||
|
assert!(verify_workspace_password(
|
||||||
|
&workspace,
|
||||||
|
Some("workspace-secret")
|
||||||
|
));
|
||||||
|
assert!(!verify_workspace_password(&workspace, Some("wrong")));
|
||||||
|
|
||||||
|
let pad = pad(Some(hash_password("pad-secret")));
|
||||||
|
assert!(verify_pad_password(&pad, Some("pad-secret")));
|
||||||
|
assert!(!verify_pad_password(&pad, Some("wrong")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4
-1
@@ -185,7 +185,7 @@ fn print_startup_credential() {
|
|||||||
|
|
||||||
fn startup_credential() -> String {
|
fn startup_credential() -> String {
|
||||||
format!(
|
format!(
|
||||||
"RustPad {}\nCopyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl",
|
"RustPad {}\nCopyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl\nLicense: https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md",
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -304,6 +304,9 @@ mod startup_tests {
|
|||||||
let credential = startup_credential();
|
let credential = startup_credential();
|
||||||
assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION"))));
|
assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION"))));
|
||||||
assert!(credential.contains("Mateusz Gruszczyński @linuxiarz.pl"));
|
assert!(credential.contains("Mateusz Gruszczyński @linuxiarz.pl"));
|
||||||
|
assert!(credential.contains(
|
||||||
|
"https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md"
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,16 +233,16 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"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"#
|
r#"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"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_LINKS => {
|
Query::RESOURCE_SHARING_LINKS => {
|
||||||
r#"SELECT token_hash, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
|
r#"SELECT token_hash, CAST(label AS CHAR CHARACTER SET utf8mb4) AS label, permission, CAST(expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at, CAST(created_at AS CHAR CHARACTER SET utf8mb4) AS created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_PENDING => {
|
Query::RESOURCE_SHARING_PENDING => {
|
||||||
r#"SELECT u.email, u.nickname, i.permission, CAST(i.expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
|
r#"SELECT u.email, u.nickname, i.permission, CAST(i.expires_at AS CHAR CHARACTER SET utf8mb4) AS expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_INSERT => {
|
Query::SHARE_LINK_INSERT => {
|
||||||
r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"#
|
r#"INSERT INTO resource_share_links (token_hash, label, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_UPDATE => {
|
Query::SHARE_LINK_UPDATE => {
|
||||||
r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
r#"UPDATE resource_share_links SET label = ?, permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_REVOKE => {
|
Query::SHARE_LINK_REVOKE => {
|
||||||
r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||||
|
|||||||
@@ -235,16 +235,16 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"#
|
r#"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = $1 AND rp.resource_slug = $2 ORDER BY u.email"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_LINKS => {
|
Query::RESOURCE_SHARING_LINKS => {
|
||||||
r#"SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#
|
r#"SELECT token_hash, label, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = $1 AND resource_slug = $2 AND revoked_at IS NULL ORDER BY created_at DESC"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_PENDING => {
|
Query::RESOURCE_SHARING_PENDING => {
|
||||||
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = $1 AND i.resource_slug = $2 AND i.accepted_at IS NULL ORDER BY u.email"#
|
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = $1 AND i.resource_slug = $2 AND i.accepted_at IS NULL ORDER BY u.email"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_INSERT => {
|
Query::SHARE_LINK_INSERT => {
|
||||||
r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6)"#
|
r#"INSERT INTO resource_share_links (token_hash, label, resource_kind, resource_slug, permission, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7)"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_UPDATE => {
|
Query::SHARE_LINK_UPDATE => {
|
||||||
r#"UPDATE resource_share_links SET permission = $1, expires_at = $2 WHERE token_hash = $3 AND resource_kind = $4 AND resource_slug = $5 AND revoked_at IS NULL"#
|
r#"UPDATE resource_share_links SET label = $1, permission = $2, expires_at = $3 WHERE token_hash = $4 AND resource_kind = $5 AND resource_slug = $6 AND revoked_at IS NULL"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_REVOKE => {
|
Query::SHARE_LINK_REVOKE => {
|
||||||
r#"UPDATE resource_share_links SET revoked_at = $1 WHERE token_hash = $2 AND resource_kind = $3 AND resource_slug = $4"#
|
r#"UPDATE resource_share_links SET revoked_at = $1 WHERE token_hash = $2 AND resource_kind = $3 AND resource_slug = $4"#
|
||||||
|
|||||||
@@ -233,16 +233,16 @@ pub fn get(query: Query) -> &'static str {
|
|||||||
r#"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"#
|
r#"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"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_LINKS => {
|
Query::RESOURCE_SHARING_LINKS => {
|
||||||
r#"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"#
|
r#"SELECT token_hash, label, 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"#
|
||||||
}
|
}
|
||||||
Query::RESOURCE_SHARING_PENDING => {
|
Query::RESOURCE_SHARING_PENDING => {
|
||||||
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
|
r#"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_INSERT => {
|
Query::SHARE_LINK_INSERT => {
|
||||||
r#"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"#
|
r#"INSERT INTO resource_share_links (token_hash, label, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_UPDATE => {
|
Query::SHARE_LINK_UPDATE => {
|
||||||
r#"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
r#"UPDATE resource_share_links SET label = ?, permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"#
|
||||||
}
|
}
|
||||||
Query::SHARE_LINK_REVOKE => {
|
Query::SHARE_LINK_REVOKE => {
|
||||||
r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
r#"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"#
|
||||||
|
|||||||
@@ -126,10 +126,6 @@ pub fn share_session_cookie(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_share_session_cookie(kind: &str, slug: &str) -> HeaderValue {
|
|
||||||
clear_cookie(&share_session_cookie_name(kind, slug))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn client_key(headers: &HeaderMap) -> String {
|
pub fn client_key(headers: &HeaderMap) -> String {
|
||||||
let forwarded_ip = header_ip(headers, "cf-connecting-ip")
|
let forwarded_ip = header_ip(headers, "cf-connecting-ip")
|
||||||
.or_else(|| header_ip(headers, "x-real-ip"))
|
.or_else(|| header_ip(headers, "x-real-ip"))
|
||||||
@@ -360,11 +356,6 @@ mod tests {
|
|||||||
assert!(value.contains("HttpOnly"));
|
assert!(value.contains("HttpOnly"));
|
||||||
assert!(value.contains("Secure"));
|
assert!(value.contains("Secure"));
|
||||||
assert!(value.contains("SameSite=Lax"));
|
assert!(value.contains("SameSite=Lax"));
|
||||||
|
|
||||||
let cleared = clear_share_session_cookie("workspace", "private-space")
|
|
||||||
.to_str()
|
|
||||||
.unwrap();
|
|
||||||
assert!(cleared.contains("Max-Age=0"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+8
-10
@@ -249,15 +249,18 @@ async fn current_resource_access(
|
|||||||
access_tokens: &[Option<&str>],
|
access_tokens: &[Option<&str>],
|
||||||
session_token: Option<&str>,
|
session_token: Option<&str>,
|
||||||
password_ok: bool,
|
password_ok: bool,
|
||||||
public_unprotected: bool,
|
|
||||||
) -> (bool, bool) {
|
) -> (bool, bool) {
|
||||||
|
if auth::resource_is_public_unprotected(state, kind, slug)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return (true, true);
|
||||||
|
}
|
||||||
let permission =
|
let permission =
|
||||||
resource_permission_from_tokens(state, kind, slug, access_tokens, session_token).await;
|
resource_permission_from_tokens(state, kind, slug, access_tokens, session_token).await;
|
||||||
let password_token_ok = password_access_from_tokens(state, kind, slug, access_tokens).await;
|
let password_token_ok = password_access_from_tokens(state, kind, slug, access_tokens).await;
|
||||||
let write_allowed = public_unprotected
|
let write_allowed =
|
||||||
|| password_ok
|
password_ok || password_token_ok || permission.as_deref() == Some("rw");
|
||||||
|| password_token_ok
|
|
||||||
|| permission.as_deref() == Some("rw");
|
|
||||||
let read_allowed = write_allowed || permission.as_deref() == Some("ro");
|
let read_allowed = write_allowed || permission.as_deref() == Some("ro");
|
||||||
(read_allowed, write_allowed)
|
(read_allowed, write_allowed)
|
||||||
}
|
}
|
||||||
@@ -451,7 +454,6 @@ async fn handle_socket(
|
|||||||
let _ = send_error(&mut socket, "Invalid password").await;
|
let _ = send_error(&mut socket, "Invalid password").await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let public_unprotected = workspace.is_private == 0 && workspace.password_hash.is_none();
|
|
||||||
let (_, write_allowed) = current_resource_access(
|
let (_, write_allowed) = current_resource_access(
|
||||||
&state,
|
&state,
|
||||||
"workspace",
|
"workspace",
|
||||||
@@ -459,7 +461,6 @@ async fn handle_socket(
|
|||||||
&external_tokens,
|
&external_tokens,
|
||||||
session_token.as_deref(),
|
session_token.as_deref(),
|
||||||
password_ok,
|
password_ok,
|
||||||
public_unprotected,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
|
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
|
||||||
@@ -520,7 +521,6 @@ async fn handle_socket(
|
|||||||
&external_tokens,
|
&external_tokens,
|
||||||
session_token.as_deref(),
|
session_token.as_deref(),
|
||||||
password_ok,
|
password_ok,
|
||||||
public_unprotected,
|
|
||||||
).await;
|
).await;
|
||||||
if !read_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await; break; }
|
if !read_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await; break; }
|
||||||
if !current_write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; }
|
if !current_write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; }
|
||||||
@@ -549,7 +549,6 @@ async fn handle_socket(
|
|||||||
&external_tokens,
|
&external_tokens,
|
||||||
session_token.as_deref(),
|
session_token.as_deref(),
|
||||||
password_ok,
|
password_ok,
|
||||||
public_unprotected,
|
|
||||||
).await;
|
).await;
|
||||||
if !read_allowed {
|
if !read_allowed {
|
||||||
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||||
@@ -564,7 +563,6 @@ async fn handle_socket(
|
|||||||
&external_tokens,
|
&external_tokens,
|
||||||
session_token.as_deref(),
|
session_token.as_deref(),
|
||||||
password_ok,
|
password_ok,
|
||||||
public_unprotected,
|
|
||||||
).await;
|
).await;
|
||||||
if !read_allowed {
|
if !read_allowed {
|
||||||
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||||
|
|||||||
@@ -218,7 +218,6 @@ async fn handle_pad_socket(
|
|||||||
.await;
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let public_unprotected = pad.is_private == 0 && pad.password_hash.is_none();
|
|
||||||
let (_, write_allowed) = current_resource_access(
|
let (_, write_allowed) = current_resource_access(
|
||||||
&state,
|
&state,
|
||||||
"pad",
|
"pad",
|
||||||
@@ -226,7 +225,6 @@ async fn handle_pad_socket(
|
|||||||
&external_tokens,
|
&external_tokens,
|
||||||
session_token.as_deref(),
|
session_token.as_deref(),
|
||||||
password_ok,
|
password_ok,
|
||||||
public_unprotected,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
|
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
|
||||||
@@ -286,7 +284,6 @@ async fn handle_pad_socket(
|
|||||||
&external_tokens,
|
&external_tokens,
|
||||||
session_token.as_deref(),
|
session_token.as_deref(),
|
||||||
password_ok,
|
password_ok,
|
||||||
public_unprotected,
|
|
||||||
).await;
|
).await;
|
||||||
if !read_allowed { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;break; }
|
if !read_allowed { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;break; }
|
||||||
if !current_write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
|
if !current_write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
|
||||||
@@ -317,7 +314,6 @@ async fn handle_pad_socket(
|
|||||||
&external_tokens,
|
&external_tokens,
|
||||||
session_token.as_deref(),
|
session_token.as_deref(),
|
||||||
password_ok,
|
password_ok,
|
||||||
public_unprotected,
|
|
||||||
).await;
|
).await;
|
||||||
if !read_allowed {
|
if !read_allowed {
|
||||||
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||||
@@ -332,7 +328,6 @@ async fn handle_pad_socket(
|
|||||||
&external_tokens,
|
&external_tokens,
|
||||||
session_token.as_deref(),
|
session_token.as_deref(),
|
||||||
password_ok,
|
password_ok,
|
||||||
public_unprotected,
|
|
||||||
).await;
|
).await;
|
||||||
if !read_allowed {
|
if !read_allowed {
|
||||||
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||||
|
|||||||
+32
-1
@@ -3873,7 +3873,7 @@ dialog::backdrop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.share-link-form {
|
.share-link-form {
|
||||||
grid-template-columns: minmax(140px, auto) minmax(110px, 140px) auto auto;
|
grid-template-columns: minmax(180px, 1fr) minmax(140px, auto) minmax(110px, 140px) auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-forever {
|
.share-forever {
|
||||||
@@ -5931,6 +5931,21 @@ dialog::backdrop {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-link-label-edit {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-label-edit>span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-label-edit input {
|
||||||
|
width: min(100%, 520px);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
@media (max-width: 800px) {
|
||||||
.share-link-row {
|
.share-link-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -6485,3 +6500,19 @@ dialog::backdrop {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Share links: keep one-time URLs readable without storing plaintext tokens. */
|
||||||
|
.share-link-id {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-once {
|
||||||
|
color: var(--muted-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-message.warning {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|||||||
+54
-9
@@ -121,6 +121,8 @@ function authHeaders() { return {}; }
|
|||||||
function escapeHtml(value) { const node = document.createElement("div"); node.textContent = String(value ?? ""); return node.innerHTML; }
|
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 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()}`; }
|
function formatShareExpiry(value) { if (!value) return "Never expires"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Expires ${date.toLocaleString()}`; }
|
||||||
|
function formatShareCreated(value) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : `Created ${date.toLocaleString()}`; }
|
||||||
|
function shareLinkId(tokenHash) { return String(tokenHash || "").slice(0, 12); }
|
||||||
function renderResourcesPagination(meta) {
|
function renderResourcesPagination(meta) {
|
||||||
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} items</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
|
resourcesPagination.innerHTML = meta.total ? `<button type="button" data-page="${meta.page - 1}" ${meta.page <= 1 ? "disabled" : ""}>Previous</button><span>Page ${meta.page} of ${meta.total_pages} · ${meta.total} items</span><button type="button" data-page="${meta.page + 1}" ${meta.page >= meta.total_pages ? "disabled" : ""}>Next</button>` : "";
|
||||||
}
|
}
|
||||||
@@ -166,6 +168,7 @@ async function loadResources() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
row.querySelector("[data-share]")?.addEventListener("click", async () => {
|
row.querySelector("[data-share]")?.addEventListener("click", async () => {
|
||||||
|
const initialShareLinksEnabled = Boolean(item.private || item.protected);
|
||||||
const dialog = document.createElement("dialog");
|
const dialog = document.createElement("dialog");
|
||||||
dialog.className = "app-dialog share-dialog";
|
dialog.className = "app-dialog share-dialog";
|
||||||
dialog.innerHTML = `<div class="share-panel">
|
dialog.innerHTML = `<div class="share-panel">
|
||||||
@@ -176,12 +179,13 @@ async function loadResources() {
|
|||||||
<label><span>Permission</span><select name="permission"><option value="ro">Read only</option><option value="rw">Read and write</option></select></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>
|
<button class="primary-button" type="submit">Grant access</button>
|
||||||
</form><div data-user-list class="share-list"></div></section>
|
</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">
|
<section class="share-section" data-direct-links-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><div data-direct-links-enabled ${initialShareLinksEnabled ? "" : "hidden"}><form data-link-form class="share-form-grid share-link-form">
|
||||||
|
<label class="share-link-label"><span>Label</span><input name="label" type="text" maxlength="120" placeholder="Client ABC" 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>
|
<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><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>
|
<label class="share-forever"><input name="forever" type="checkbox"><span>Never expires</span></label>
|
||||||
<button class="primary-button" type="submit">Create link</button>
|
<button class="primary-button" type="submit">Create link</button>
|
||||||
</form><div class="share-link-list-wrap"><h5>Individual links</h5><div data-link-list class="share-list share-link-list"></div></div></section>
|
</form><div class="share-link-list-wrap"><h5>Individual links</h5><div data-link-list class="share-list share-link-list"></div></div></div><div class="share-empty" data-direct-links-disabled ${initialShareLinksEnabled ? "hidden" : ""}>Direct links are disabled while this item is public and has no password. Existing links are preserved and become active again after you make it private or add a password.</div></section>
|
||||||
</div>
|
</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 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>`;
|
</div>`;
|
||||||
@@ -200,24 +204,65 @@ async function loadResources() {
|
|||||||
const linkForm = dialog.querySelector("[data-link-form]");
|
const linkForm = dialog.querySelector("[data-link-form]");
|
||||||
const userList = dialog.querySelector("[data-user-list]");
|
const userList = dialog.querySelector("[data-user-list]");
|
||||||
const linkList = dialog.querySelector("[data-link-list]");
|
const linkList = dialog.querySelector("[data-link-list]");
|
||||||
|
const directLinksEnabled = dialog.querySelector("[data-direct-links-enabled]");
|
||||||
|
const directLinksDisabled = dialog.querySelector("[data-direct-links-disabled]");
|
||||||
|
const createdLinkValues = new Map();
|
||||||
const syncForever = () => { linkForm.hours.disabled = linkForm.forever.checked; };
|
const syncForever = () => { linkForm.hours.disabled = linkForm.forever.checked; };
|
||||||
linkForm.forever.addEventListener("change", syncForever); syncForever();
|
linkForm.forever.addEventListener("change", syncForever); syncForever();
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const d = await api(`/api/auth/resources/sharing?kind=${encodeURIComponent(item.kind)}&slug=${encodeURIComponent(item.slug)}`, { headers: authHeaders() });
|
const d = await api(`/api/auth/resources/sharing?kind=${encodeURIComponent(item.kind)}&slug=${encodeURIComponent(item.slug)}`, { headers: authHeaders() });
|
||||||
|
const shareLinksEnabled = d.share_links_enabled !== false;
|
||||||
|
directLinksEnabled.hidden = !shareLinksEnabled;
|
||||||
|
directLinksDisabled.hidden = shareLinksEnabled;
|
||||||
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>';
|
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 => {
|
linkList.innerHTML = shareLinksEnabled && d.links.length ? d.links.map(link => {
|
||||||
const linkPreview = '<small class="share-link-legacy">For security, link values are shown only when created. Create a new link to copy it again.</small>';
|
const identifier = shareLinkId(link.token_hash);
|
||||||
return `<form class="share-list-row share-link-row" data-link-token="${escapeHtml(link.token_hash)}"><div class="share-link-info"><strong>Individual link</strong><small>${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</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>`;
|
const visibleLink = createdLinkValues.get(link.token_hash);
|
||||||
|
const linkPreview = visibleLink
|
||||||
|
? `<div class="share-link-inline"><input type="text" readonly value="${escapeHtml(visibleLink)}" aria-label="New share link"><button class="secondary-button compact-button" type="button" data-copy-link>Copy</button></div><small class="share-link-once">The full address remains visible until this dialog is closed.</small>`
|
||||||
|
: `<small class="share-link-legacy">The full address is not stored. Use the label or link ID #${escapeHtml(identifier)} to identify it.</small>`;
|
||||||
|
return `<form class="share-list-row share-link-row" data-link-token-hash="${escapeHtml(link.token_hash)}"><div class="share-link-info"><label class="share-link-label-edit"><span>Label</span><input name="label" type="text" maxlength="120" value="${escapeHtml(link.label || "")}" placeholder="Unlabeled link" autocomplete="off"></label><small>Link ID #${escapeHtml(identifier)} · ${escapeHtml(formatShareCreated(link.created_at))} · ${escapeHtml(formatShareExpiry(link.expires_at))}</small>${linkPreview}</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>';
|
}).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; } }));
|
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 => {
|
linkList.querySelectorAll("[data-link-token-hash]").forEach(linkRow => {
|
||||||
const forever = linkRow.elements.forever, hours = linkRow.elements.hours; const sync = () => { hours.disabled = forever.checked; }; forever.addEventListener("change", sync); sync();
|
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-copy-link]")?.addEventListener("click", async event => {
|
||||||
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"); } });
|
const button = event.currentTarget;
|
||||||
|
const input = linkRow.querySelector(".share-link-inline input");
|
||||||
|
try {
|
||||||
|
button.disabled = true;
|
||||||
|
await copyText(input.value);
|
||||||
|
setDialogMessage("Link copied.", "success");
|
||||||
|
} catch (err) {
|
||||||
|
setDialogMessage(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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_hash: linkRow.dataset.linkTokenHash, label: linkRow.elements.label.value, 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_hash: linkRow.dataset.linkTokenHash }) }); createdLinkValues.delete(linkRow.dataset.linkTokenHash); setDialogMessage("Link revoked.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, recipients: userForm.recipients.value, permission: userForm.permission.value }) }); userForm.recipients.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "Access granted.", "success"); await refresh(); } catch (err) { setDialogMessage(err.message, "error"); } });
|
userForm.addEventListener("submit", async event => { event.preventDefault(); try { const result = await api("/api/auth/resources/sharing", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, recipients: userForm.recipients.value, permission: userForm.permission.value }) }); userForm.recipients.value = ""; setDialogMessage(result.confirmation_required ? "Invitation sent. Access will appear after the recipient accepts it." : "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 copyText(absolute); setDialogMessage("Link created and copied. For security, it is shown only once.", "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, label: linkForm.label.value, permission: linkForm.permission.value, expires_at }) });
|
||||||
|
const absolute = new URL(result.url, location.origin).href;
|
||||||
|
createdLinkValues.set(result.token_hash, absolute);
|
||||||
|
linkForm.label.value = "";
|
||||||
|
await refresh();
|
||||||
|
try {
|
||||||
|
await copyText(absolute);
|
||||||
|
setDialogMessage("Link created, displayed below and copied.", "success");
|
||||||
|
} catch {
|
||||||
|
setDialogMessage("Link created and displayed below. Automatic copying failed; use the Copy button.", "warning");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setDialogMessage(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
|
try { await refresh(); } catch (err) { setDialogMessage(err.message, "error"); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ def main() -> None:
|
|||||||
migrations = sorted(MIGRATIONS.glob("*.sql"))
|
migrations = sorted(MIGRATIONS.glob("*.sql"))
|
||||||
before_sessions = [path for path in migrations if path.name < "0026_share_link_sessions.sql"]
|
before_sessions = [path for path in migrations if path.name < "0026_share_link_sessions.sql"]
|
||||||
session_migration = MIGRATIONS / "0026_share_link_sessions.sql"
|
session_migration = MIGRATIONS / "0026_share_link_sessions.sql"
|
||||||
|
label_migration = MIGRATIONS / "0027_share_link_labels.sql"
|
||||||
|
|
||||||
db = sqlite3.connect(":memory:")
|
db = sqlite3.connect(":memory:")
|
||||||
db.execute("PRAGMA foreign_keys = ON")
|
db.execute("PRAGMA foreign_keys = ON")
|
||||||
@@ -74,14 +75,15 @@ def main() -> None:
|
|||||||
share_hash = hashlib.sha256(raw_link.encode()).hexdigest()
|
share_hash = hashlib.sha256(raw_link.encode()).hexdigest()
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT INTO resource_share_links "
|
"INSERT INTO resource_share_links "
|
||||||
"(token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) "
|
"(token_hash, resource_kind, resource_slug, permission, expires_at, created_by) "
|
||||||
"VALUES (?, ?, 'workspace', 'private-space', 'ro', NULL, 1)",
|
"VALUES (?, 'workspace', 'private-space', 'ro', NULL, 1)",
|
||||||
(share_hash, raw_link),
|
(share_hash,),
|
||||||
)
|
)
|
||||||
db.executescript(session_migration.read_text())
|
db.executescript(session_migration.read_text())
|
||||||
assert db.execute(
|
db.executescript(label_migration.read_text())
|
||||||
"SELECT token FROM resource_share_links WHERE token_hash = ?", (share_hash,)
|
columns = {row[1] for row in db.execute("PRAGMA table_info(resource_share_links)")}
|
||||||
).fetchone() == (None,), "migration must erase persisted plaintext share tokens"
|
assert "token" not in columns, "fresh schema must not contain a plaintext token column"
|
||||||
|
assert "label" in columns, "share links must support labels"
|
||||||
|
|
||||||
source_sql = query("SHARE_LINK_SESSION_SOURCE")
|
source_sql = query("SHARE_LINK_SESSION_SOURCE")
|
||||||
insert_sql = query("SHARE_SESSION_INSERT")
|
insert_sql = query("SHARE_SESSION_INSERT")
|
||||||
@@ -162,23 +164,24 @@ def main() -> None:
|
|||||||
).rowcount
|
).rowcount
|
||||||
assert deleted == 1, "revoking a link must remove all derived sessions"
|
assert deleted == 1, "revoking a link must remove all derived sessions"
|
||||||
|
|
||||||
# Newly created links persist only the hash; the plaintext column stays NULL.
|
# Newly created links persist only the hash and an optional identifying label.
|
||||||
second_raw = "one-time-returned-token"
|
second_raw = "one-time-returned-token"
|
||||||
second_hash = hashlib.sha256(second_raw.encode()).hexdigest()
|
second_hash = hashlib.sha256(second_raw.encode()).hexdigest()
|
||||||
db.execute(
|
db.execute(
|
||||||
link_insert_sql,
|
link_insert_sql,
|
||||||
(second_hash, "workspace", "second-space", "rw", None, 1),
|
(second_hash, "QA link", "workspace", "second-space", "rw", None, 1),
|
||||||
)
|
)
|
||||||
assert db.execute(
|
assert db.execute(
|
||||||
"SELECT token, permission FROM resource_share_links WHERE token_hash = ?", (second_hash,)
|
"SELECT label, permission FROM resource_share_links WHERE token_hash = ?", (second_hash,)
|
||||||
).fetchone() == (None, "rw")
|
).fetchone() == ("QA link", "rw")
|
||||||
listed_link = db.execute(sharing_list_sql, ("workspace", "second-space")).fetchone()
|
listed_link = db.execute(sharing_list_sql, ("workspace", "second-space")).fetchone()
|
||||||
assert listed_link == (
|
assert listed_link == (
|
||||||
second_hash,
|
second_hash,
|
||||||
|
"QA link",
|
||||||
"rw",
|
"rw",
|
||||||
None,
|
None,
|
||||||
listed_link[3],
|
listed_link[4],
|
||||||
), "management listing must never return the plaintext share token"
|
), "management listing must return the label but never a plaintext share token"
|
||||||
|
|
||||||
db.execute(
|
db.execute(
|
||||||
insert_sql,
|
insert_sql,
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ def query_sql(backend: str, name: str) -> str:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
names = {
|
names = {
|
||||||
"SHARE_LINK_INSERT": 6,
|
"SHARE_LINK_INSERT": 7,
|
||||||
|
"SHARE_LINK_UPDATE": 6,
|
||||||
"SHARE_LINK_PERMISSION": 3,
|
"SHARE_LINK_PERMISSION": 3,
|
||||||
"SHARE_LINK_SESSION_SOURCE": 3,
|
"SHARE_LINK_SESSION_SOURCE": 3,
|
||||||
"SHARE_SESSION_INSERT": 5,
|
"SHARE_SESSION_INSERT": 5,
|
||||||
@@ -43,6 +44,8 @@ def main() -> None:
|
|||||||
assert sorted(set(parameters)) == list(range(1, expected + 1)), (name, sql)
|
assert sorted(set(parameters)) == list(range(1, expected + 1)), (name, sql)
|
||||||
|
|
||||||
auth = read("src/auth/mod.rs")
|
auth = read("src/auth/mod.rs")
|
||||||
|
register_response = auth[auth.index("pub struct RegisterResponse"):auth.index("pub async fn identity")]
|
||||||
|
assert "token:" not in register_response
|
||||||
assert ".bind(&token)" not in auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
|
assert ".bind(&token)" not in auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
|
||||||
assert '"token":row.token' not in auth
|
assert '"token":row.token' not in auth
|
||||||
assert "token: Option<String>" not in auth[auth.index("struct SharingLinkRow"):auth.index("struct ShareLinkSessionSource")]
|
assert "token: Option<String>" not in auth[auth.index("struct SharingLinkRow"):auth.index("struct ShareLinkSessionSource")]
|
||||||
@@ -55,10 +58,29 @@ def main() -> None:
|
|||||||
assert 'format!("share-session-client:{client_key}")' in auth
|
assert 'format!("share-session-client:{client_key}")' in auth
|
||||||
create_link = auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
|
create_link = auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
|
||||||
assert "no-store, max-age=0" in create_link
|
assert "no-store, max-age=0" in create_link
|
||||||
|
assert '"token_hash":token_hash' in create_link
|
||||||
|
assert '"token":token' not in create_link
|
||||||
|
assert '"label":label' in create_link
|
||||||
create_session = auth[auth.index("pub async fn create_share_session"):auth.index("async fn share_session_permission")]
|
create_session = auth[auth.index("pub async fn create_share_session"):auth.index("async fn share_session_permission")]
|
||||||
assert "existing_session_token" in create_session
|
assert "existing_session_token" not in create_session
|
||||||
assert 'source.permission == "ro"' in create_session
|
assert 'source.permission == "ro"' not in create_session
|
||||||
assert '== Some("rw")' in create_session
|
assert "resource_is_public_unprotected(state, kind, slug).await?" in create_session
|
||||||
|
assert create_session.index("resource_is_public_unprotected") < create_session.index("valid_share_token")
|
||||||
|
create_link = auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
|
||||||
|
update_link = auth[auth.index("pub async fn update_share_link"):auth.index("pub async fn revoke_share_link")]
|
||||||
|
revoke_link = auth[auth.index("pub async fn revoke_share_link"):auth.index("fn validate_permission")]
|
||||||
|
for management_handler in (create_link, update_link, revoke_link):
|
||||||
|
assert "ensure_share_links_enabled" in management_handler
|
||||||
|
assert "Direct share links are disabled for public resources without a password." in auth
|
||||||
|
share_access = auth[auth.index("pub async fn share_access_permission"):auth.index("pub async fn share_link_permission")]
|
||||||
|
assert "resource_is_public_unprotected(state, kind, slug).await?" in share_access
|
||||||
|
sharing = auth[auth.index("pub async fn resource_sharing"):auth.index("pub async fn create_share_link")]
|
||||||
|
assert '"share_links_enabled":share_links_enabled' in sharing
|
||||||
|
assert "if share_links_enabled" in sharing
|
||||||
|
assert "Vec::new()" in sharing
|
||||||
|
privacy = auth[auth.index("pub async fn set_resource_privacy"):auth.index("pub async fn share_resource_users")]
|
||||||
|
assert "SHARE_LINK" not in privacy
|
||||||
|
assert "SHARE_SESSION" not in privacy
|
||||||
|
|
||||||
api = read("src/api/mod.rs")
|
api = read("src/api/mod.rs")
|
||||||
assert "combined_token_access_level" not in api
|
assert "combined_token_access_level" not in api
|
||||||
@@ -68,7 +90,16 @@ def main() -> None:
|
|||||||
|
|
||||||
access_tokens = read("src/api/access_tokens.rs")
|
access_tokens = read("src/api/access_tokens.rs")
|
||||||
assert "verify_password_access_token" in access_tokens
|
assert "verify_password_access_token" in access_tokens
|
||||||
assert "share_access_permission" in access_tokens
|
assert "verify_resource_access_token" not in access_tokens
|
||||||
|
assert "workspace.password_hash.is_some()" in access_tokens
|
||||||
|
assert "pad.password_hash.is_some()" in access_tokens
|
||||||
|
|
||||||
|
database = read("src/db/mod.rs")
|
||||||
|
workspace_password = database[database.index("pub fn verify_workspace_password"):database.index("pub async fn list_notes")]
|
||||||
|
pad_password = database[database.index("pub fn verify_pad_password"):database.index("pub async fn save_pad_revision")]
|
||||||
|
assert "(None, _) => false" in workspace_password
|
||||||
|
assert "(None, _) => false" in pad_password
|
||||||
|
assert "missing_password_does_not_grant_password_access" in database
|
||||||
|
|
||||||
websocket = read("src/websocket/mod.rs") + read("src/websocket/pad.rs")
|
websocket = read("src/websocket/mod.rs") + read("src/websocket/pad.rs")
|
||||||
assert "cookie_share_session_token" in websocket
|
assert "cookie_share_session_token" in websocket
|
||||||
@@ -79,6 +110,9 @@ def main() -> None:
|
|||||||
assert "access_refresh" in websocket
|
assert "access_refresh" in websocket
|
||||||
assert websocket.count("update=updates.recv()=>{") == 2
|
assert websocket.count("update=updates.recv()=>{") == 2
|
||||||
assert websocket.count('message:"Access expired or revoked"') >= 6
|
assert websocket.count('message:"Access expired or revoked"') >= 6
|
||||||
|
current_access = read("src/websocket/mod.rs")[read("src/websocket/mod.rs").index("async fn current_resource_access"):read("src/websocket/mod.rs").index("// Merged from note.rs")]
|
||||||
|
assert "resource_is_public_unprotected(state, kind, slug)" in current_access
|
||||||
|
assert "public_unprotected: bool" not in current_access
|
||||||
|
|
||||||
pages = read("src/app/pages.rs")
|
pages = read("src/app/pages.rs")
|
||||||
assert "canonical_resource_url" in pages
|
assert "canonical_resource_url" in pages
|
||||||
@@ -95,20 +129,40 @@ def main() -> None:
|
|||||||
security = read("src/security.rs")
|
security = read("src/security.rs")
|
||||||
assert "__Host-rustpad_share_" in security
|
assert "__Host-rustpad_share_" in security
|
||||||
assert "HttpOnly; Secure; SameSite=Lax" in security
|
assert "HttpOnly; Secure; SameSite=Lax" in security
|
||||||
|
assert "clear_share_session_cookie" not in security
|
||||||
|
|
||||||
for backend in ("sqlite", "postgres", "mysql"):
|
for backend in ("sqlite", "postgres", "mysql"):
|
||||||
sharing_list = query_sql(backend, "RESOURCE_SHARING_LINKS")
|
sharing_list = query_sql(backend, "RESOURCE_SHARING_LINKS")
|
||||||
assert not re.search(r"(?:^|,)\s*(?:CAST\()?token\b", sharing_list)
|
assert not re.search(r"(?:^|,)\s*(?:CAST\()?token\b", sharing_list)
|
||||||
|
legacy_migration = read(f"migrations/{backend}/0012_share_link_tokens.sql")
|
||||||
|
assert "ADD COLUMN token" not in legacy_migration
|
||||||
migration = read(f"migrations/{backend}/0026_share_link_sessions.sql")
|
migration = read(f"migrations/{backend}/0026_share_link_sessions.sql")
|
||||||
assert "UPDATE resource_share_links SET token = NULL" in migration
|
assert "resource_share_links SET token" not in migration
|
||||||
assert "session_token_hash" in migration
|
assert "session_token_hash" in migration
|
||||||
assert "ON DELETE CASCADE" in migration
|
assert "ON DELETE CASCADE" in migration
|
||||||
|
label_migration = read(f"migrations/{backend}/0027_share_link_labels.sql")
|
||||||
|
assert "ADD COLUMN label" in label_migration
|
||||||
|
|
||||||
|
home_js = read("static/js/home.js")
|
||||||
|
assert "createdLinkValues" in home_js
|
||||||
|
assert "result.token_hash" in home_js
|
||||||
|
assert "data-copy-link" in home_js
|
||||||
|
assert "The full address is not stored" in home_js
|
||||||
|
assert 'name="label"' in home_js
|
||||||
|
assert "token_hash: linkRow.dataset.linkTokenHash" in home_js
|
||||||
|
assert "Link created, displayed below and copied." in home_js
|
||||||
|
assert "data-direct-links-disabled" in home_js
|
||||||
|
assert "d.share_links_enabled !== false" in home_js
|
||||||
|
assert "Existing links are preserved and become active again" in home_js
|
||||||
|
|
||||||
workspace_js = read("static/js/workspace.js")
|
workspace_js = read("static/js/workspace.js")
|
||||||
note_js = read("static/js/note-editor.js")
|
note_js = read("static/js/note-editor.js")
|
||||||
assert 'info.access_level === "none"' in workspace_js
|
assert 'info.access_level === "none"' in workspace_js
|
||||||
assert note_js.count('info.access_level === "none"') >= 2
|
assert note_js.count('info.access_level === "none"') >= 2
|
||||||
|
|
||||||
|
main_rs = read("src/main.rs")
|
||||||
|
assert "https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md" in main_rs
|
||||||
|
|
||||||
app = read("src/app/mod.rs")
|
app = read("src/app/mod.rs")
|
||||||
assert "PathOnlyMakeSpan" in app
|
assert "PathOnlyMakeSpan" in app
|
||||||
assert "request.uri().path()" in app
|
assert "request.uri().path()" in app
|
||||||
|
|||||||
+71
-48
@@ -2,66 +2,89 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
globalThis.location = { origin: "https://pad.example" };
|
const origin = "https://pad.example";
|
||||||
|
let currentUrl;
|
||||||
|
let historyCall;
|
||||||
|
let dispatchedEvent;
|
||||||
|
|
||||||
|
globalThis.CustomEvent = class CustomEvent {
|
||||||
|
constructor(type, init = {}) {
|
||||||
|
this.type = type;
|
||||||
|
this.detail = init.detail;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.window = {
|
||||||
|
location: null,
|
||||||
|
history: {
|
||||||
|
pushState(state, _title, url) {
|
||||||
|
historyCall = { method: "pushState", state };
|
||||||
|
setLocation(url);
|
||||||
|
},
|
||||||
|
replaceState(state, _title, url) {
|
||||||
|
historyCall = { method: "replaceState", state };
|
||||||
|
setLocation(url);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dispatchEvent(event) {
|
||||||
|
dispatchedEvent = event;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function setLocation(value) {
|
||||||
|
currentUrl = new URL(value, origin);
|
||||||
|
window.location = {
|
||||||
|
get href() { return currentUrl.href; },
|
||||||
|
get search() { return currentUrl.search; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWindow(value) {
|
||||||
|
setLocation(value);
|
||||||
|
historyCall = undefined;
|
||||||
|
dispatchedEvent = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const source = await readFile(new URL("../static/js/url-state.js", import.meta.url), "utf8");
|
const source = await readFile(new URL("../static/js/url-state.js", import.meta.url), "utf8");
|
||||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||||
const { editorResourceUrl, withShareToken } = await import(moduleUrl);
|
const { currentShareUrl, readEditorState, writeEditorState } = await import(moduleUrl);
|
||||||
|
|
||||||
function parsed(path) {
|
test("editor state reads supported values", () => {
|
||||||
return new URL(path, "https://pad.example");
|
resetWindow("/w/demo/n/note?view=preview&mode=text");
|
||||||
}
|
assert.deepEqual(readEditorState(), { view: "preview", mode: "text" });
|
||||||
|
|
||||||
test("workspace share token is preserved when opening a note", () => {
|
|
||||||
const url = parsed(editorResourceUrl("/w/private/n/first", {
|
|
||||||
shareToken: "share-token-123",
|
|
||||||
view: "split",
|
|
||||||
mode: "markdown",
|
|
||||||
}));
|
|
||||||
|
|
||||||
assert.equal(url.pathname, "/w/private/n/first");
|
|
||||||
assert.equal(url.searchParams.get("share"), "share-token-123");
|
|
||||||
assert.equal(url.searchParams.get("view"), "split");
|
|
||||||
assert.equal(url.searchParams.get("mode"), "markdown");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("share token is preserved when returning to the workspace", () => {
|
test("editor state rejects unsupported values", () => {
|
||||||
const url = parsed(withShareToken("/w/private", "share-token-123"));
|
resetWindow("/w/demo/n/note?view=invalid&mode=html");
|
||||||
|
assert.deepEqual(readEditorState(), { view: "split", mode: "markdown" });
|
||||||
assert.equal(url.pathname, "/w/private");
|
|
||||||
assert.equal(url.searchParams.get("share"), "share-token-123");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("existing query and hash survive share-aware navigation", () => {
|
test("writing editor state preserves unrelated query and hash", () => {
|
||||||
const url = parsed(editorResourceUrl("/w/private/n/first?mode=text#section", {
|
resetWindow("/w/demo/n/note?filter=recent#section");
|
||||||
shareToken: "new-token",
|
const result = new URL(writeEditorState({ view: "edit", mode: "text" }));
|
||||||
view: "preview",
|
|
||||||
mode: "markdown",
|
|
||||||
}));
|
|
||||||
|
|
||||||
assert.equal(url.searchParams.get("share"), "new-token");
|
assert.equal(result.searchParams.get("filter"), "recent");
|
||||||
assert.equal(url.searchParams.get("view"), "preview");
|
assert.equal(result.searchParams.get("view"), "edit");
|
||||||
assert.equal(url.searchParams.get("mode"), "markdown");
|
assert.equal(result.searchParams.get("mode"), "text");
|
||||||
assert.equal(url.hash, "#section");
|
assert.equal(result.hash, "#section");
|
||||||
|
assert.equal(historyCall.method, "pushState");
|
||||||
|
assert.deepEqual(historyCall.state, { view: "edit", mode: "text" });
|
||||||
|
assert.equal(dispatchedEvent.type, "rustpad:urlchange");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ordinary workspace navigation does not gain or retain a share token", () => {
|
test("replace mode uses replaceState", () => {
|
||||||
const url = parsed(editorResourceUrl("/w/public/n/first?share=stale-token", {
|
resetWindow("/p/demo");
|
||||||
view: "split",
|
writeEditorState({ view: "split", mode: "markdown" }, { replace: true });
|
||||||
mode: "markdown",
|
assert.equal(historyCall.method, "replaceState");
|
||||||
}));
|
|
||||||
|
|
||||||
assert.equal(url.searchParams.has("share"), false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("current share URL adds view state without inventing access tokens", () => {
|
||||||
|
resetWindow("/w/demo/n/note?filter=recent#section");
|
||||||
|
const result = new URL(currentShareUrl({ view: "preview", mode: "markdown" }));
|
||||||
|
|
||||||
test("share-aware helpers reject external application URLs", () => {
|
assert.equal(result.searchParams.get("filter"), "recent");
|
||||||
assert.equal(withShareToken("https://example.com/steal", "secret"), "/");
|
assert.equal(result.searchParams.get("view"), "preview");
|
||||||
});
|
assert.equal(result.searchParams.get("mode"), "markdown");
|
||||||
|
assert.equal(result.searchParams.has("share"), false);
|
||||||
|
assert.equal(result.hash, "#section");
|
||||||
test("share-aware helpers accept same-origin absolute URLs", () => {
|
|
||||||
const url = parsed(withShareToken("https://pad.example/w/private", "share-token-123"));
|
|
||||||
assert.equal(url.pathname, "/w/private");
|
|
||||||
assert.equal(url.searchParams.get("share"), "share-token-123");
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user