share invitation

This commit is contained in:
Mateusz Gruszczyński
2026-07-24 10:20:27 +02:00
parent c728f27dcd
commit f52cf91470
19 changed files with 5512 additions and 685 deletions
+82 -5
View File
@@ -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,