big update in share links

This commit is contained in:
Mateusz Gruszczyński
2026-08-03 09:56:47 +02:00
parent e3ee6319b9
commit fe5d00fcdd
27 changed files with 434 additions and 176 deletions
+98 -35
View File
@@ -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);