share funxtions
This commit is contained in:
+122
-3
@@ -37,8 +37,18 @@ pub struct User {
|
||||
kind: String, slug: String, #[serde(default)] password: Option<String>
|
||||
}
|
||||
#[derive(Serialize, FromRow)] pub struct ResourceItem {
|
||||
slug: String, title: String, protected: i64, updated_at: String
|
||||
slug: String, title: String, protected: i64, updated_at: String, #[sqlx(rename = "private")]
|
||||
#[serde(rename = "private")]
|
||||
private_resource: i64, owned: i64, permission: String, shared_by: String
|
||||
}
|
||||
|
||||
#[derive(Deserialize)] pub struct PrivacyRequest { kind: String, slug: String, private: bool }
|
||||
#[derive(Deserialize)] pub struct ShareUsersRequest { kind: String, slug: String, emails: String, permission: String }
|
||||
#[derive(Deserialize)] pub struct RemoveShareRequest { kind: String, slug: String, email: String }
|
||||
#[derive(Deserialize)] pub struct CreateShareLinkRequest { kind: String, slug: String, permission: String, expires_at: Option<String> }
|
||||
#[derive(Deserialize)] pub struct UpdateShareLinkRequest { kind: String, slug: String, token: String, permission: String, expires_at: Option<String> }
|
||||
#[derive(Deserialize)] pub struct RevokeShareLinkRequest { kind: String, slug: String, token: String }
|
||||
|
||||
#[derive(Serialize)] pub struct ResourceList {
|
||||
workspaces: Vec<ResourceItem>, pads: Vec<ResourceItem>
|
||||
}
|
||||
@@ -161,8 +171,8 @@ pub async fn me(State(state): State<SharedState>, headers: HeaderMap) -> Result<
|
||||
|
||||
pub async fn resources(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<ResourceList>, AuthError> {
|
||||
let user = require_user(&state, &headers).await?;
|
||||
let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_WORKSPACES)).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let pads = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_WORKSPACES)).bind(user.id).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let pads = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)).bind(user.id).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
Ok(Json(ResourceList { workspaces, pads }))
|
||||
}
|
||||
|
||||
@@ -202,6 +212,115 @@ async fn ensure_owner(state: &SharedState, user_id: i64, kind: &str, slug: &str)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
pub async fn set_resource_privacy(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<PrivacyRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let user = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, user.id, &req.kind, &req.slug).await?;
|
||||
let table = match req.kind.as_str() { "workspace" => "workspaces", "pad" => "pads", _ => return Err(AuthError::bad_request("Unknown resource type.")) };
|
||||
let query = format!("UPDATE {table} SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?");
|
||||
sqlx::query(&query).bind(req.private).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn share_resource_users(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<ShareUsersRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let owner = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||
let permission = validate_permission(&req.permission)?;
|
||||
let emails: Vec<String> = req.emails.split(',').map(|v| normalize(v)).filter(|v| !v.is_empty()).collect();
|
||||
if emails.is_empty() || emails.len() > 100 { return Err(AuthError::bad_request("Enter between 1 and 100 registered e-mail addresses.")); }
|
||||
let mut missing = Vec::new();
|
||||
for email in emails {
|
||||
let user = find_user_by_email(&state, &email).await?;
|
||||
let Some(user) = user else { missing.push(email); continue; };
|
||||
if user.id == owner.id { continue; }
|
||||
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
|
||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)"))
|
||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
}
|
||||
if !missing.is_empty() { return Err(AuthError::bad_request(&format!("No registered account for: {}", missing.join(", ")))); }
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn remove_resource_user(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<RemoveShareRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let owner = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||
let email = normalize(&req.email);
|
||||
if let Some(user) = find_user_by_email(&state, &email).await? {
|
||||
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
|
||||
.bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
}
|
||||
Ok(Json(serde_json::json!({"ok":true})))
|
||||
}
|
||||
|
||||
pub async fn resource_sharing(State(state): State<SharedState>, headers: HeaderMap, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String,String>>) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let owner = require_user(&state, &headers).await?;
|
||||
let kind = params.get("kind").ok_or_else(|| AuthError::bad_request("Missing kind."))?;
|
||||
let slug = params.get("slug").ok_or_else(|| AuthError::bad_request("Missing slug."))?;
|
||||
ensure_owner(&state, owner.id, kind, slug).await?;
|
||||
let users: Vec<(String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), "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"))
|
||||
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let links: Vec<(String,String,Option<String>,String)> = sqlx::query_as(queries::get(state.db.kind(), "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"))
|
||||
.bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
Ok(Json(serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::<Vec<_>>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::<Vec<_>>() })))
|
||||
}
|
||||
|
||||
pub async fn create_share_link(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<CreateShareLinkRequest>) -> Result<Json<serde_json::Value>, 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 token = random_token(); let token_hash = hash_token(&token);
|
||||
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"))
|
||||
.bind(token_hash).bind(&req.kind).bind(req.slug.trim()).bind(permission).bind(&req.expires_at).bind(owner.id).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let base = if req.kind == "workspace" { format!("/w/{}", req.slug.trim()) } 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})))
|
||||
}
|
||||
|
||||
|
||||
pub async fn update_share_link(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<UpdateShareLinkRequest>) -> Result<Json<serde_json::Value>, 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 result = sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"))
|
||||
.bind(permission).bind(&req.expires_at).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
if result.rows_affected() == 0 { return Err(AuthError::bad_request("Share link was not found or is already revoked.")); }
|
||||
Ok(Json(serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at})))
|
||||
}
|
||||
|
||||
pub async fn revoke_share_link(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<RevokeShareLinkRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
let owner = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||
sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"))
|
||||
.bind(Utc::now().to_rfc3339()).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})))
|
||||
}
|
||||
|
||||
fn validate_permission(value: &str) -> Result<&str, AuthError> { match value { "ro"|"rw" => Ok(value), _ => Err(AuthError::bad_request("Permission must be ro or rw.")) } }
|
||||
|
||||
fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> {
|
||||
let Some(value) = value else { return Ok(()); };
|
||||
let expires = chrono::DateTime::parse_from_rfc3339(value).map_err(|_| AuthError::bad_request("Invalid expiration date."))?.with_timezone(&Utc);
|
||||
if expires <= Utc::now() { return Err(AuthError::bad_request("Expiration must be in the future.")); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resource_permission(state: &SharedState, kind: &str, slug: &str, token: Option<&str>) -> Result<Option<String>, AuthError> {
|
||||
let Some(token) = token.filter(|v| !v.is_empty()) else { return Ok(None); };
|
||||
if let Some(user) = user_from_token(state, token).await? {
|
||||
let owns = ensure_owner(state, user.id, kind, slug).await.is_ok();
|
||||
if owns { return Ok(Some("rw".into())); }
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
|
||||
.bind(kind).bind(slug).bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
return Ok(permission);
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let permission: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"))
|
||||
.bind(hash_token(token)).bind(kind).bind(slug).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
Ok(permission)
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<SharedState>, headers: HeaderMap) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
if let Some(token) = bearer(&headers) {
|
||||
let result = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN)).bind(token).execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
|
||||
Reference in New Issue
Block a user