share invitation
This commit is contained in:
+82
-5
@@ -1,5 +1,5 @@
|
||||
use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2};
|
||||
use axum::{extract::State, http::{HeaderMap, StatusCode}, Json};
|
||||
use axum::{extract::{Path as AxumPath, State}, http::{HeaderMap, StatusCode}, response::Redirect, Json};
|
||||
use chrono::{Duration, Utc};
|
||||
use lettre::{
|
||||
message::{header::ContentType, Mailbox, MultiPart, SinglePart},
|
||||
@@ -226,6 +226,9 @@ pub async fn share_resource_users(State(state): State<SharedState>, headers: Hea
|
||||
let owner = require_user(&state, &headers).await?;
|
||||
ensure_owner(&state, owner.id, &req.kind, &req.slug).await?;
|
||||
let permission = validate_permission(&req.permission)?;
|
||||
if state.share_confirmation_required && state.smtp.is_none() {
|
||||
return Err(AuthError::service_unavailable("Share confirmation requires SMTP configuration."));
|
||||
}
|
||||
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();
|
||||
@@ -233,13 +236,52 @@ pub async fn share_resource_users(State(state): State<SharedState>, headers: Hea
|
||||
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)?;
|
||||
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations 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)?;
|
||||
|
||||
if state.share_confirmation_required {
|
||||
let token = random_token();
|
||||
let token_hash = hash_token(&token);
|
||||
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
|
||||
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)"))
|
||||
.bind(&token_hash).bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).bind(owner.id).bind(&expires_at)
|
||||
.execute(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
if let Err(error) = send_share_invitation(state.smtp.as_ref().unwrap(), &owner, &user, &req.kind, req.slug.trim(), permission, &token).await {
|
||||
let _ = sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE token_hash = ?"))
|
||||
.bind(&token_hash).execute(state.db.pool()).await;
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
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})))
|
||||
Ok(Json(serde_json::json!({"ok":true,"confirmation_required":state.share_confirmation_required})))
|
||||
}
|
||||
|
||||
pub async fn accept_share_invitation(State(state): State<SharedState>, AxumPath(token): AxumPath<String>) -> Result<Redirect, AuthError> {
|
||||
let token_hash = hash_token(token.trim());
|
||||
let row: Option<(String, String, i64, String, String, Option<String>)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?"))
|
||||
.bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
|
||||
let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| AuthError::bad_request("The sharing invitation is invalid or has expired."))?;
|
||||
let expires = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The sharing invitation is invalid or has expired."))?.with_timezone(&Utc);
|
||||
if accepted_at.is_none() {
|
||||
if expires <= Utc::now() { return Err(AuthError::bad_request("The sharing invitation is invalid or has expired.")); }
|
||||
let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"))
|
||||
.bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).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(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?"))
|
||||
.bind(Utc::now().to_rfc3339()).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?;
|
||||
tx.commit().await.map_err(AuthError::database)?;
|
||||
}
|
||||
let target = if kind == "workspace" { format!("/w/{slug}") } else { format!("/p/{slug}") };
|
||||
Ok(Redirect::to(&target))
|
||||
}
|
||||
|
||||
pub async fn remove_resource_user(State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<RemoveShareRequest>) -> Result<Json<serde_json::Value>, AuthError> {
|
||||
@@ -249,6 +291,8 @@ pub async fn remove_resource_user(State(state): State<SharedState>, headers: Hea
|
||||
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)?;
|
||||
sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations 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})))
|
||||
}
|
||||
@@ -262,7 +306,9 @@ pub async fn resource_sharing(State(state): State<SharedState>, headers: HeaderM
|
||||
.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<_>>() })))
|
||||
let pending: Vec<(String,String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), "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"))
|
||||
.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<_>>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).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> {
|
||||
@@ -609,6 +655,37 @@ async fn send_registration_email(
|
||||
send_message(smtp, message, "registration e-mail").await
|
||||
}
|
||||
|
||||
async fn send_share_invitation(
|
||||
smtp: &SmtpConfig,
|
||||
owner: &User,
|
||||
recipient_user: &User,
|
||||
kind: &str,
|
||||
slug: &str,
|
||||
permission: &str,
|
||||
token: &str,
|
||||
) -> Result<(), AuthError> {
|
||||
let site = smtp.public_url.trim_end_matches('/');
|
||||
let accept_url = format!("{site}/share-invitations/{token}/accept");
|
||||
let resource_label = if kind == "workspace" { "workspace" } else { "note" };
|
||||
let access_label = if permission == "rw" { "view and edit" } else { "view" };
|
||||
let sender = smtp.from.parse::<Mailbox>().map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
|
||||
let recipient = recipient_user.email.parse::<Mailbox>().map_err(|_| AuthError::internal("Invalid recipient address."))?;
|
||||
let subject = format!("{} shared a RustPad {} with you", owner.nickname, resource_label);
|
||||
let text_body = format!(
|
||||
"Hello {},\n\n{} shared the {} '{}' with you ({access_label}).\nAccept the invitation within 7 days:\n{}\n\nIf you were not expecting this invitation, ignore this message.",
|
||||
recipient_user.nickname, owner.nickname, resource_label, slug, accept_url
|
||||
);
|
||||
let html_body = format!(r#"<!doctype html><html lang="en"><body style="margin:0;padding:24px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b"><div style="max-width:560px;margin:0 auto;padding:24px;background:#fff;border-radius:10px"><h1 style="margin-top:0;font-size:22px">A RustPad {resource_label} was shared with you</h1><p>Hello {},</p><p><strong>{}</strong> shared <strong>{}</strong> with you. Permission: <strong>{access_label}</strong>.</p><p><a href="{}" style="display:inline-block;padding:11px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px">Accept invitation</a></p><p style="font-size:13px;color:#52525b">This link expires in 7 days.</p></div></body></html>"#,
|
||||
recipient_user.nickname, owner.nickname, slug, accept_url
|
||||
);
|
||||
let message = Message::builder().from(sender).to(recipient).subject(subject)
|
||||
.multipart(MultiPart::alternative()
|
||||
.singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(text_body))
|
||||
.singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(html_body)))
|
||||
.map_err(|_| AuthError::internal("Failed to build sharing invitation e-mail."))?;
|
||||
send_message(smtp, message, "sharing invitation e-mail").await
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
smtp: &SmtpConfig,
|
||||
message: Message,
|
||||
|
||||
Reference in New Issue
Block a user