big update in share links
This commit is contained in:
+16
-20
@@ -96,25 +96,6 @@ pub async fn create_resource_access_token(
|
||||
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(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
@@ -134,7 +115,22 @@ pub(crate) async fn verify_password_access_token(
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.fetch_one(state.db.pool())
|
||||
.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 {
|
||||
|
||||
@@ -86,7 +86,6 @@ async fn share_session_redirect(
|
||||
kind,
|
||||
slug,
|
||||
share,
|
||||
crate::security::share_session_token(headers, kind, slug),
|
||||
&client_key,
|
||||
)
|
||||
.await
|
||||
|
||||
+98
-35
@@ -40,6 +40,7 @@ use crate::{
|
||||
const MIN_PASSWORD: usize = 8;
|
||||
const MAX_PASSWORD: usize = 128;
|
||||
const MAX_NICKNAME: usize = 40;
|
||||
const MAX_SHARE_LINK_LABEL: usize = 120;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
@@ -149,6 +150,7 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow {
|
||||
#[derive(Debug)]
|
||||
struct SharingLinkRow {
|
||||
token_hash: String,
|
||||
label: Option<String>,
|
||||
permission: String,
|
||||
expires_at: Option<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> {
|
||||
Ok(Self {
|
||||
token_hash: crate::row_decode::text(row, 0)?,
|
||||
permission: crate::row_decode::text(row, 1)?,
|
||||
expires_at: crate::row_decode::optional_text(row, 2)?,
|
||||
created_at: crate::row_decode::text(row, 3)?,
|
||||
label: crate::row_decode::optional_text(row, 1)?,
|
||||
permission: crate::row_decode::text(row, 2)?,
|
||||
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 {
|
||||
kind: String,
|
||||
slug: String,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
permission: String,
|
||||
expires_at: Option<String>,
|
||||
}
|
||||
@@ -300,7 +305,9 @@ pub struct CreateShareLinkRequest {
|
||||
pub struct UpdateShareLinkRequest {
|
||||
kind: String,
|
||||
slug: String,
|
||||
token: String,
|
||||
token_hash: String,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
permission: String,
|
||||
expires_at: Option<String>,
|
||||
}
|
||||
@@ -308,7 +315,7 @@ pub struct UpdateShareLinkRequest {
|
||||
pub struct RevokeShareLinkRequest {
|
||||
kind: String,
|
||||
slug: String,
|
||||
token: String,
|
||||
token_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -361,8 +368,6 @@ pub struct IdentityResponse {
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterResponse {
|
||||
#[serde(skip_serializing)]
|
||||
token: Option<String>,
|
||||
nickname: String,
|
||||
email: String,
|
||||
expires_at: Option<String>,
|
||||
@@ -514,7 +519,6 @@ pub async fn register(
|
||||
return Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(RegisterResponse {
|
||||
token: None,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
expires_at: None,
|
||||
@@ -534,7 +538,6 @@ pub async fn register(
|
||||
let mut response = (
|
||||
StatusCode::CREATED,
|
||||
Json(RegisterResponse {
|
||||
token: Some(session.token),
|
||||
nickname: session.nickname,
|
||||
email: session.email,
|
||||
expires_at: Some(session.expires_at),
|
||||
@@ -1441,6 +1444,40 @@ async fn ensure_owner(
|
||||
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(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
@@ -1706,6 +1743,7 @@ pub async fn resource_sharing(
|
||||
.get("slug")
|
||||
.ok_or_else(|| AuthError::bad_request("Missing slug."))?;
|
||||
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(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_SHARING_USERS,
|
||||
@@ -1715,15 +1753,19 @@ pub async fn resource_sharing(
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let links: Vec<SharingLinkRow> = sqlx::query_as(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_SHARING_LINKS,
|
||||
))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let links: Vec<SharingLinkRow> = if share_links_enabled {
|
||||
sqlx::query_as(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_SHARING_LINKS,
|
||||
))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.fetch_all(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let pending: Vec<PendingShareRow> = sqlx::query_as(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_SHARING_PENDING,
|
||||
@@ -1734,7 +1776,7 @@ pub async fn resource_sharing(
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
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> {
|
||||
let owner = require_user(&state, &headers).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 label = normalize_share_link_label(req.label.as_deref())?;
|
||||
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
|
||||
let token = random_token();
|
||||
let token_hash = hash_token(&token);
|
||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
|
||||
.bind(token_hash)
|
||||
.bind(&token_hash)
|
||||
.bind(&label)
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.bind(permission)
|
||||
@@ -1765,7 +1810,7 @@ pub async fn create_share_link(
|
||||
format!("/p/{}", req.slug.trim())
|
||||
};
|
||||
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();
|
||||
response.headers_mut().insert(
|
||||
@@ -1786,12 +1831,15 @@ pub async fn update_share_link(
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let owner = require_user(&state, &headers).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 label = normalize_share_link_label(req.label.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))
|
||||
.bind(&label)
|
||||
.bind(permission)
|
||||
.bind(&expires_at)
|
||||
.bind(req.token.trim())
|
||||
.bind(req.token_hash.trim())
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.execute(state.db.pool())
|
||||
@@ -1803,7 +1851,7 @@ pub async fn update_share_link(
|
||||
));
|
||||
}
|
||||
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> {
|
||||
let owner = require_user(&state, &headers).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))
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(req.token.trim())
|
||||
.bind(req.token_hash.trim())
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.execute(state.db.pool())
|
||||
@@ -1826,7 +1875,7 @@ pub async fn revoke_share_link(
|
||||
state.db.kind(),
|
||||
queries::SHARE_SESSIONS_DELETE_BY_LINK,
|
||||
))
|
||||
.bind(req.token.trim())
|
||||
.bind(req.token_hash.trim())
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.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> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
@@ -1903,9 +1970,11 @@ pub async fn create_share_session(
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
share_token: &str,
|
||||
existing_session_token: Option<&str>,
|
||||
client_key: &str,
|
||||
) -> Result<Option<ShareSession>, AuthError> {
|
||||
if resource_is_public_unprotected(state, kind, slug).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
let share_token = share_token.trim();
|
||||
if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") {
|
||||
return Ok(None);
|
||||
@@ -1939,15 +2008,6 @@ pub async fn create_share_session(
|
||||
warn!(kind, slug, "invalid share link permission in database");
|
||||
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 expires_at = match source.expires_at.as_deref() {
|
||||
Some(value) => match chrono::DateTime::parse_from_rfc3339(value) {
|
||||
@@ -2079,6 +2139,9 @@ pub async fn share_access_permission(
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> 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?;
|
||||
if permission.is_some() {
|
||||
return Ok(permission);
|
||||
|
||||
+56
-2
@@ -157,7 +157,7 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>)
|
||||
&workspace.password_hash,
|
||||
password.filter(|value| !value.is_empty()),
|
||||
) {
|
||||
(None, _) => true,
|
||||
(None, _) => false,
|
||||
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
||||
.ok()
|
||||
.and_then(|parsed| {
|
||||
@@ -433,7 +433,7 @@ pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
|
||||
&pad.password_hash,
|
||||
password.filter(|value| !value.is_empty()),
|
||||
) {
|
||||
(None, _) => true,
|
||||
(None, _) => false,
|
||||
(Some(hash), Some(password)) => PasswordHash::new(hash)
|
||||
.ok()
|
||||
.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 {
|
||||
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")
|
||||
)
|
||||
}
|
||||
@@ -304,6 +304,9 @@ mod startup_tests {
|
||||
let credential = startup_credential();
|
||||
assert!(credential.contains(&format!("RustPad {}", env!("CARGO_PKG_VERSION"))));
|
||||
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"#
|
||||
}
|
||||
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 => {
|
||||
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 => {
|
||||
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 => {
|
||||
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 => {
|
||||
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"#
|
||||
}
|
||||
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 => {
|
||||
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 => {
|
||||
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 => {
|
||||
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 => {
|
||||
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"#
|
||||
}
|
||||
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 => {
|
||||
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 => {
|
||||
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 => {
|
||||
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 => {
|
||||
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 {
|
||||
let forwarded_ip = header_ip(headers, "cf-connecting-ip")
|
||||
.or_else(|| header_ip(headers, "x-real-ip"))
|
||||
@@ -360,11 +356,6 @@ mod tests {
|
||||
assert!(value.contains("HttpOnly"));
|
||||
assert!(value.contains("Secure"));
|
||||
assert!(value.contains("SameSite=Lax"));
|
||||
|
||||
let cleared = clear_share_session_cookie("workspace", "private-space")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(cleared.contains("Max-Age=0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+8
-10
@@ -249,15 +249,18 @@ async fn current_resource_access(
|
||||
access_tokens: &[Option<&str>],
|
||||
session_token: Option<&str>,
|
||||
password_ok: bool,
|
||||
public_unprotected: bool,
|
||||
) -> (bool, bool) {
|
||||
if auth::resource_is_public_unprotected(state, kind, slug)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (true, true);
|
||||
}
|
||||
let permission =
|
||||
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 write_allowed = public_unprotected
|
||||
|| password_ok
|
||||
|| password_token_ok
|
||||
|| permission.as_deref() == Some("rw");
|
||||
let write_allowed =
|
||||
password_ok || password_token_ok || permission.as_deref() == Some("rw");
|
||||
let read_allowed = write_allowed || permission.as_deref() == Some("ro");
|
||||
(read_allowed, write_allowed)
|
||||
}
|
||||
@@ -451,7 +454,6 @@ async fn handle_socket(
|
||||
let _ = send_error(&mut socket, "Invalid password").await;
|
||||
return;
|
||||
}
|
||||
let public_unprotected = workspace.is_private == 0 && workspace.password_hash.is_none();
|
||||
let (_, write_allowed) = current_resource_access(
|
||||
&state,
|
||||
"workspace",
|
||||
@@ -459,7 +461,6 @@ async fn handle_socket(
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
)
|
||||
.await;
|
||||
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
|
||||
@@ -520,7 +521,6 @@ async fn handle_socket(
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
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; }
|
||||
@@ -549,7 +549,6 @@ async fn handle_socket(
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||
@@ -564,7 +563,6 @@ async fn handle_socket(
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _=send_split(&mut sender,&ServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||
|
||||
@@ -218,7 +218,6 @@ async fn handle_pad_socket(
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let public_unprotected = pad.is_private == 0 && pad.password_hash.is_none();
|
||||
let (_, write_allowed) = current_resource_access(
|
||||
&state,
|
||||
"pad",
|
||||
@@ -226,7 +225,6 @@ async fn handle_pad_socket(
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
)
|
||||
.await;
|
||||
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
|
||||
@@ -286,7 +284,6 @@ async fn handle_pad_socket(
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
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;}
|
||||
@@ -317,7 +314,6 @@ async fn handle_pad_socket(
|
||||
&external_tokens,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
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,
|
||||
session_token.as_deref(),
|
||||
password_ok,
|
||||
public_unprotected,
|
||||
).await;
|
||||
if !read_allowed {
|
||||
let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Access expired or revoked".into()}).await;
|
||||
|
||||
Reference in New Issue
Block a user