big update in share links
This commit is contained in:
+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);
|
||||
|
||||
Reference in New Issue
Block a user