fix2 tokens
This commit is contained in:
+307
-23
@@ -149,7 +149,6 @@ impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow {
|
||||
#[derive(Debug)]
|
||||
struct SharingLinkRow {
|
||||
token_hash: String,
|
||||
token: Option<String>,
|
||||
permission: String,
|
||||
expires_at: Option<String>,
|
||||
created_at: String,
|
||||
@@ -159,14 +158,68 @@ 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)?,
|
||||
token: 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)?,
|
||||
permission: crate::row_decode::text(row, 1)?,
|
||||
expires_at: crate::row_decode::optional_text(row, 2)?,
|
||||
created_at: crate::row_decode::text(row, 3)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShareLinkSessionSource {
|
||||
token_hash: String,
|
||||
permission: String,
|
||||
expires_at: Option<String>,
|
||||
}
|
||||
|
||||
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkSessionSource {
|
||||
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)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShareLinkPermissionRow {
|
||||
permission: String,
|
||||
expires_at: Option<String>,
|
||||
}
|
||||
|
||||
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareLinkPermissionRow {
|
||||
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
permission: crate::row_decode::text(row, 0)?,
|
||||
expires_at: crate::row_decode::optional_text(row, 1)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ShareSessionPermissionRow {
|
||||
permission: String,
|
||||
session_expires_at: String,
|
||||
link_expires_at: Option<String>,
|
||||
}
|
||||
|
||||
impl<'r> sqlx::FromRow<'r, AnyRow> for ShareSessionPermissionRow {
|
||||
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
permission: crate::row_decode::text(row, 0)?,
|
||||
session_expires_at: crate::row_decode::text(row, 1)?,
|
||||
link_expires_at: crate::row_decode::optional_text(row, 2)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ShareSession {
|
||||
pub token: String,
|
||||
pub max_age_seconds: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PendingShareRow {
|
||||
email: String,
|
||||
@@ -1681,7 +1734,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,"token":row.token,"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,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::<Vec<_>>() }),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1689,20 +1742,19 @@ pub async fn create_share_link(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<CreateShareLinkRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
) -> Result<Response, AuthError> {
|
||||
let owner = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||
let permission = validate_permission(&req.permission)?;
|
||||
validate_share_expiration(req.expires_at.as_deref())?;
|
||||
let 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)
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.bind(permission)
|
||||
.bind(&req.expires_at)
|
||||
.bind(&expires_at)
|
||||
.bind(owner.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
@@ -1712,9 +1764,19 @@ pub async fn create_share_link(
|
||||
} else {
|
||||
format!("/p/{}", req.slug.trim())
|
||||
};
|
||||
Ok(Json(
|
||||
serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":req.expires_at}),
|
||||
))
|
||||
let mut response = Json(
|
||||
serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":expires_at}),
|
||||
)
|
||||
.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
"no-store, max-age=0".parse().expect("valid cache-control"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::PRAGMA,
|
||||
"no-cache".parse().expect("valid pragma"),
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn update_share_link(
|
||||
@@ -1725,10 +1787,10 @@ pub async fn update_share_link(
|
||||
let owner = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||
let permission = validate_permission(&req.permission)?;
|
||||
validate_share_expiration(req.expires_at.as_deref())?;
|
||||
let expires_at = normalize_share_expiration(req.expires_at.as_deref())?;
|
||||
let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE))
|
||||
.bind(permission)
|
||||
.bind(&req.expires_at)
|
||||
.bind(&expires_at)
|
||||
.bind(req.token.trim())
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
@@ -1741,7 +1803,7 @@ pub async fn update_share_link(
|
||||
));
|
||||
}
|
||||
Ok(Json(
|
||||
serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at}),
|
||||
serde_json::json!({"ok":true,"permission":permission,"expires_at":expires_at}),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1760,6 +1822,16 @@ pub async fn revoke_share_link(
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_SESSIONS_DELETE_BY_LINK,
|
||||
))
|
||||
.bind(req.token.trim())
|
||||
.bind(&req.kind)
|
||||
.bind(req.slug.trim())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
@@ -1770,9 +1842,9 @@ fn validate_permission(value: &str) -> Result<&str, AuthError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
|
||||
fn normalize_share_expiration(value: Option<&str>) -> Result<Option<String>, AuthError> {
|
||||
let Some(value) = value else {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
};
|
||||
let expires = chrono::DateTime::parse_from_rfc3339(value)
|
||||
.map_err(|_| AuthError::bad_request("Invalid expiration date."))?
|
||||
@@ -1780,7 +1852,7 @@ fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
|
||||
if expires <= Utc::now() {
|
||||
return Err(AuthError::bad_request("Expiration must be in the future."));
|
||||
}
|
||||
Ok(())
|
||||
Ok(Some(expires.to_rfc3339()))
|
||||
}
|
||||
|
||||
pub async fn is_resource_owner(
|
||||
@@ -1826,6 +1898,194 @@ pub async fn account_resource_permission(
|
||||
Ok(permission)
|
||||
}
|
||||
|
||||
pub async fn create_share_session(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
share_token: &str,
|
||||
existing_session_token: Option<&str>,
|
||||
client_key: &str,
|
||||
) -> Result<Option<ShareSession>, AuthError> {
|
||||
let share_token = share_token.trim();
|
||||
if !valid_share_token(share_token) || !matches!(kind, "workspace" | "pad") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let now_text = now.to_rfc3339();
|
||||
let window = std::time::Duration::from_secs(15 * 60);
|
||||
state
|
||||
.check_rate_limit(format!("share-session-client:{client_key}"), 120, window)
|
||||
.await
|
||||
.map_err(|seconds| {
|
||||
AuthError::rate_limited(&format!(
|
||||
"Too many share-link attempts. Try again in {seconds} seconds."
|
||||
))
|
||||
})?;
|
||||
let source = sqlx::query_as::<_, ShareLinkSessionSource>(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_LINK_SESSION_SOURCE,
|
||||
))
|
||||
.bind(hash_token(share_token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let Some(source) = source else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !matches!(source.permission.as_str(), "ro" | "rw") {
|
||||
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) {
|
||||
Ok(value) => std::cmp::min(value.with_timezone(&Utc), session_limit),
|
||||
Err(error) => {
|
||||
warn!(%error, kind, slug, "invalid share link expiration in database");
|
||||
return Ok(None);
|
||||
}
|
||||
},
|
||||
None => session_limit,
|
||||
};
|
||||
let max_age_seconds = (expires_at - now).num_seconds();
|
||||
if max_age_seconds <= 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
state
|
||||
.check_rate_limit(
|
||||
format!("share-session-client:{client_key}:{}", source.token_hash),
|
||||
60,
|
||||
window,
|
||||
)
|
||||
.await
|
||||
.map_err(|seconds| {
|
||||
AuthError::rate_limited(&format!(
|
||||
"Too many share-link sessions. Try again in {seconds} seconds."
|
||||
))
|
||||
})?;
|
||||
state
|
||||
.check_rate_limit(
|
||||
format!("share-session-link:{}", source.token_hash),
|
||||
2_000,
|
||||
std::time::Duration::from_secs(60 * 60),
|
||||
)
|
||||
.await
|
||||
.map_err(|seconds| {
|
||||
AuthError::rate_limited(&format!(
|
||||
"Too many share-link sessions. Try again in {seconds} seconds."
|
||||
))
|
||||
})?;
|
||||
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_SESSIONS_DELETE_EXPIRED,
|
||||
))
|
||||
.bind(&now_text)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
|
||||
let token = random_token();
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_SESSION_INSERT,
|
||||
))
|
||||
.bind(hash_token(&token))
|
||||
.bind(source.token_hash)
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(expires_at.to_rfc3339())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
|
||||
Ok(Some(ShareSession {
|
||||
token,
|
||||
max_age_seconds,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn share_session_permission(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<Option<String>, AuthError> {
|
||||
let Some(token) = token.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !valid_share_token(token) {
|
||||
return Ok(None);
|
||||
}
|
||||
let row = sqlx::query_as::<_, ShareSessionPermissionRow>(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_SESSION_PERMISSION,
|
||||
))
|
||||
.bind(hash_token(token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !matches!(row.permission.as_str(), "ro" | "rw") {
|
||||
warn!(kind, slug, "invalid share session permission in database");
|
||||
return Ok(None);
|
||||
}
|
||||
let now = Utc::now();
|
||||
let session_expires = match chrono::DateTime::parse_from_rfc3339(&row.session_expires_at) {
|
||||
Ok(value) => value.with_timezone(&Utc),
|
||||
Err(error) => {
|
||||
warn!(%error, kind, slug, "invalid share session expiration in database");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if session_expires <= now {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(value) = row.link_expires_at.as_deref() {
|
||||
let link_expires = match chrono::DateTime::parse_from_rfc3339(value) {
|
||||
Ok(value) => value.with_timezone(&Utc),
|
||||
Err(error) => {
|
||||
warn!(%error, kind, slug, "invalid share link expiration in database");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if link_expires <= now {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(Some(row.permission))
|
||||
}
|
||||
|
||||
pub async fn share_access_permission(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<Option<String>, AuthError> {
|
||||
let permission = share_session_permission(state, kind, slug, token).await?;
|
||||
if permission.is_some() {
|
||||
return Ok(permission);
|
||||
}
|
||||
share_link_permission(state, kind, slug, token).await
|
||||
}
|
||||
|
||||
pub async fn share_link_permission(
|
||||
state: &SharedState,
|
||||
kind: &str,
|
||||
@@ -1835,19 +2095,39 @@ pub async fn share_link_permission(
|
||||
let Some(token) = token.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(
|
||||
if !valid_share_token(token) {
|
||||
return Ok(None);
|
||||
}
|
||||
let row = sqlx::query_as::<_, ShareLinkPermissionRow>(queries::get(
|
||||
state.db.kind(),
|
||||
queries::SHARE_LINK_PERMISSION,
|
||||
))
|
||||
.bind(hash_token(token))
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.bind(now)
|
||||
.fetch_optional(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
Ok(permission)
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !matches!(row.permission.as_str(), "ro" | "rw") {
|
||||
warn!(kind, slug, "invalid share link permission in database");
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(value) = row.expires_at.as_deref() {
|
||||
let expires = match chrono::DateTime::parse_from_rfc3339(value) {
|
||||
Ok(value) => value.with_timezone(&Utc),
|
||||
Err(error) => {
|
||||
warn!(%error, kind, slug, "invalid share link expiration in database");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if expires <= Utc::now() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(Some(row.permission))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
@@ -2286,6 +2566,10 @@ fn random_token() -> String {
|
||||
random_hex_token::<32>()
|
||||
}
|
||||
|
||||
fn valid_share_token(value: &str) -> bool {
|
||||
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn random_confirmation_token() -> String {
|
||||
random_hex_token::<32>()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user