1066 lines
37 KiB
Rust
1066 lines
37 KiB
Rust
//! Owner-controlled explicit/all-user shares. Recipients never receive secrets.
|
|
use crate::{ApiError, AppState, auth::AuthUser};
|
|
use axum::{
|
|
Extension, Json, Router,
|
|
extract::{Path, Query, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
routing::{get, patch, post},
|
|
};
|
|
use serde_json::{Value, json};
|
|
use sqlx::{Row, SqliteConnection, SqlitePool};
|
|
use std::collections::{HashMap, HashSet};
|
|
type ResultResponse = Result<Response, ApiError>;
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route(
|
|
"/twofaccounts/{id}/shares",
|
|
get(index).post(store).delete(destroy_all),
|
|
)
|
|
.route("/twofaccounts/{id}/shares/all", post(share_all))
|
|
.route(
|
|
"/twofaccounts/{id}/shares/{user}",
|
|
axum::routing::delete(destroy),
|
|
)
|
|
.route("/twofaccounts/{id}/recipients", get(recipients))
|
|
.route("/twofaccounts/{id}/owner", patch(transfer))
|
|
}
|
|
pub async fn flags(db: &SqlitePool) -> Result<(bool, bool), ApiError> {
|
|
let settings = crate::settings::all(db).await?;
|
|
Ok((
|
|
crate::settings::php_bool(&settings["enableSharing"]),
|
|
crate::settings::php_bool(&settings["enableAllUsersSharingScope"]),
|
|
))
|
|
}
|
|
/// Shared visibility and OTP permission; secret reads and mutations remain owner-only.
|
|
pub async fn can_access(db: &SqlitePool, user: i64, account: i64) -> Result<bool, ApiError> {
|
|
let (enabled, all) = flags(db).await?;
|
|
Ok(sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM twofaccounts a WHERE a.id=? AND (a.user_id=? OR (? AND EXISTS(SELECT 1 FROM twofaccount_shares s WHERE s.twofaccount_id=a.id AND ((s.scope='user' AND s.shared_with_user_id=?) OR (? AND s.scope='all_users'))))))")
|
|
.bind(account).bind(user).bind(enabled).bind(user).bind(all).fetch_one(db).await?)
|
|
}
|
|
async fn authorize(s: &AppState, user: i64, account: i64) -> Result<bool, ApiError> {
|
|
let (enabled, all) = flags(&s.db).await?;
|
|
if !enabled {
|
|
return Err(ApiError(
|
|
StatusCode::FORBIDDEN,
|
|
"Sharing feature is disabled",
|
|
));
|
|
}
|
|
let owner: Option<i64> = sqlx::query_scalar("SELECT user_id FROM twofaccounts WHERE id=?")
|
|
.bind(account)
|
|
.fetch_optional(&s.db)
|
|
.await?;
|
|
match owner {
|
|
None => Err(ApiError(StatusCode::NOT_FOUND, "not found")),
|
|
Some(owner) if owner != user => Err(ApiError(
|
|
StatusCode::FORBIDDEN,
|
|
"This action is unauthorized.",
|
|
)),
|
|
_ => Ok(all),
|
|
}
|
|
}
|
|
async fn verify_owner(
|
|
conn: &mut SqliteConnection,
|
|
user: i64,
|
|
account: i64,
|
|
) -> Result<(), ApiError> {
|
|
let valid: bool =
|
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM twofaccounts WHERE id=? AND user_id=?)")
|
|
.bind(account)
|
|
.bind(user)
|
|
.fetch_one(conn)
|
|
.await?;
|
|
if !valid {
|
|
return Err(ApiError(
|
|
StatusCode::FORBIDDEN,
|
|
"This action is unauthorized.",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
async fn is_all(conn: &mut SqliteConnection, account: i64) -> Result<bool, ApiError> {
|
|
Ok(sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM twofaccount_shares WHERE twofaccount_id=? AND scope='all_users')").bind(account).fetch_one(conn).await?)
|
|
}
|
|
fn conflict() -> Response {
|
|
(
|
|
StatusCode::CONFLICT,
|
|
Json(
|
|
json!({"message":"conflict","reason":"This account is already shared with all users."}),
|
|
),
|
|
)
|
|
.into_response()
|
|
}
|
|
fn invalid(field: &str) -> Response {
|
|
(
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
Json(json!({"message":"bad request","reason":{field:["The given data was invalid."]}})),
|
|
)
|
|
.into_response()
|
|
}
|
|
fn integer(v: &Value) -> Option<i64> {
|
|
v.as_i64().or_else(|| v.as_str()?.parse().ok())
|
|
}
|
|
async fn recipient_values(
|
|
db: &SqlitePool,
|
|
account: i64,
|
|
viewer: i64,
|
|
ids: Option<&[i64]>,
|
|
exclude: Option<i64>,
|
|
filter: Option<&str>,
|
|
) -> Result<Vec<Value>, ApiError> {
|
|
let raw: Option<String> = sqlx::query_scalar("SELECT preferences FROM users WHERE id=?")
|
|
.bind(viewer)
|
|
.fetch_optional(db)
|
|
.await?
|
|
.flatten();
|
|
let preferences = crate::settings::preferences_from(raw.as_deref());
|
|
let timezone = preferences["timezone"]
|
|
.as_str()
|
|
.and_then(|tz| tz.parse::<chrono_tz::Tz>().ok())
|
|
.unwrap_or(chrono_tz::UTC);
|
|
let rows = sqlx::query("SELECT u.id,u.name,u.email,(SELECT created_at FROM twofaccount_shares s WHERE s.twofaccount_id=? AND s.shared_with_user_id=u.id AND s.scope='user' LIMIT 1) AS shared_since,EXISTS(SELECT 1 FROM twofaccount_shares s WHERE s.twofaccount_id=? AND s.shared_with_user_id=u.id AND s.scope='user') AS shared FROM users u ORDER BY u.id").bind(account).bind(account).fetch_all(db).await?;
|
|
let mut result = vec![];
|
|
for row in rows {
|
|
let id: i64 = row.try_get("id")?;
|
|
if exclude == Some(id) || ids.is_some_and(|ids| !ids.contains(&id)) {
|
|
continue;
|
|
}
|
|
let name: String = row.try_get("name")?;
|
|
if let Some(filter) = filter {
|
|
let email: String = row.try_get("email")?;
|
|
if filter.is_empty()
|
|
|| (if filter.contains('@') {
|
|
email != filter
|
|
} else {
|
|
!name.to_lowercase().contains(&filter.to_lowercase())
|
|
})
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
let shared: bool = row.try_get("shared")?;
|
|
let mut value = json!({"id":id,"name":name,"is_shared_with":shared});
|
|
if shared {
|
|
let timestamp: Option<String> = row.try_get("shared_since")?;
|
|
value["is_shared_since"] = json!(timestamp.map(|t| {
|
|
chrono::NaiveDateTime::parse_from_str(&t, "%Y-%m-%d %H:%M:%S")
|
|
.map(|d| {
|
|
d.and_utc()
|
|
.with_timezone(&timezone)
|
|
.format("%a, %b %-d, %Y %-I:%M %p")
|
|
.to_string()
|
|
})
|
|
.unwrap_or(t)
|
|
}));
|
|
}
|
|
result.push(value);
|
|
}
|
|
Ok(result)
|
|
}
|
|
async fn index(
|
|
State(s): State<AppState>,
|
|
Extension(u): Extension<AuthUser>,
|
|
Path(id): Path<i64>,
|
|
) -> ResultResponse {
|
|
let all = authorize(&s, u.id, id).await?;
|
|
let shared_all = if all {
|
|
let mut conn = s.db.acquire().await?;
|
|
is_all(&mut conn, id).await?
|
|
} else {
|
|
false
|
|
};
|
|
let mut value = json!({"twofaccount_id":id,"is_shared_with_all":shared_all});
|
|
if !shared_all {
|
|
let ids: Vec<i64> = sqlx::query_scalar("SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user'").bind(id).fetch_all(&s.db).await?;
|
|
value["specific_users"] =
|
|
json!(recipient_values(&s.db, id, u.id, Some(&ids), None, None).await?);
|
|
}
|
|
Ok(Json(value).into_response())
|
|
}
|
|
async fn store(
|
|
State(s): State<AppState>,
|
|
Extension(u): Extension<AuthUser>,
|
|
h: HeaderMap,
|
|
Path(id): Path<i64>,
|
|
Json(v): Json<Value>,
|
|
) -> ResultResponse {
|
|
let all = authorize(&s, u.id, id).await?;
|
|
let values = v
|
|
.get("user_ids")
|
|
.and_then(Value::as_array)
|
|
.cloned()
|
|
.or_else(|| v.get("user_id").map(|id| vec![id.clone()]));
|
|
let Some(values) = values else {
|
|
return Ok(invalid("user_ids"));
|
|
};
|
|
let ids: Option<Vec<i64>> = values.iter().map(integer).collect();
|
|
let Some(mut ids) = ids else {
|
|
return Ok(invalid("user_ids"));
|
|
};
|
|
if ids.is_empty()
|
|
|| ids.iter().collect::<HashSet<_>>().len() != ids.len()
|
|
|| ids.contains(&u.id)
|
|
{
|
|
return Ok(invalid("user_ids"));
|
|
}
|
|
ids.sort_unstable();
|
|
let mut tx = s.db.begin().await?;
|
|
verify_owner(&mut tx, u.id, id).await?;
|
|
if all && is_all(&mut tx, id).await? {
|
|
return Ok(conflict());
|
|
}
|
|
for target in &ids {
|
|
if !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id=?)")
|
|
.bind(target)
|
|
.fetch_one(&mut *tx)
|
|
.await?
|
|
{
|
|
return Ok(invalid("user_ids"));
|
|
}
|
|
}
|
|
if !all {
|
|
sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND scope='all_users'")
|
|
.bind(id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
let mut created = 0;
|
|
let mut notified = vec![];
|
|
for target in &ids {
|
|
let inserted = sqlx::query("INSERT OR IGNORE INTO twofaccount_shares(twofaccount_id,shared_with_user_id,scope,created_by_user_id,created_at,updated_at) VALUES(?,?,'user',?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(id).bind(target).bind(u.id).execute(&mut *tx).await?.rows_affected();
|
|
created += inserted;
|
|
if inserted > 0 {
|
|
notified.push(*target);
|
|
}
|
|
}
|
|
tx.commit().await?;
|
|
notify_shares(&s, u.id, id, ¬ified, Notification::Shared, false, &h).await;
|
|
Ok((if created>0 {StatusCode::CREATED} else {StatusCode::OK},Json(json!({"twofaccount_id":id,"is_shared_with_all":false,"specific_users":recipient_values(&s.db,id,u.id,Some(&ids),None,None).await?}))).into_response())
|
|
}
|
|
async fn recipients(
|
|
State(s): State<AppState>,
|
|
Extension(u): Extension<AuthUser>,
|
|
Path(id): Path<i64>,
|
|
Query(q): Query<HashMap<String, String>>,
|
|
) -> ResultResponse {
|
|
authorize(&s, u.id, id).await?;
|
|
if q.keys()
|
|
.any(|k| k.starts_with("filter[") && k != "filter[nameOrEmail]")
|
|
{
|
|
return Err(ApiError(
|
|
StatusCode::BAD_REQUEST,
|
|
"Requested filter is not allowed.",
|
|
));
|
|
}
|
|
Ok(Json(json!(
|
|
recipient_values(
|
|
&s.db,
|
|
id,
|
|
u.id,
|
|
None,
|
|
Some(u.id),
|
|
q.get("filter[nameOrEmail]").map(String::as_str)
|
|
)
|
|
.await?
|
|
))
|
|
.into_response())
|
|
}
|
|
async fn destroy(
|
|
State(s): State<AppState>,
|
|
Extension(u): Extension<AuthUser>,
|
|
h: HeaderMap,
|
|
Path((id, target)): Path<(i64, i64)>,
|
|
) -> ResultResponse {
|
|
let all = authorize(&s, u.id, id).await?;
|
|
let mut tx = s.db.begin().await?;
|
|
verify_owner(&mut tx, u.id, id).await?;
|
|
if !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id=?)")
|
|
.bind(target)
|
|
.fetch_one(&mut *tx)
|
|
.await?
|
|
{
|
|
return Err(ApiError(StatusCode::NOT_FOUND, "not found"));
|
|
}
|
|
if all && is_all(&mut tx, id).await? {
|
|
return Ok(conflict());
|
|
}
|
|
sqlx::query("DELETE FROM twofaccount_user_orders WHERE twofaccount_id=? AND user_id=? AND EXISTS(SELECT 1 FROM twofaccount_shares WHERE twofaccount_id=? AND shared_with_user_id=? AND scope='user')").bind(id).bind(target).bind(id).bind(target).execute(&mut *tx).await?;
|
|
let deleted=sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND shared_with_user_id=? AND scope='user'").bind(id).bind(target).execute(&mut *tx).await?.rows_affected();
|
|
tx.commit().await?;
|
|
if deleted > 0 {
|
|
notify_shares(&s, u.id, id, &[target], Notification::Revoked, false, &h).await;
|
|
}
|
|
Ok(StatusCode::NO_CONTENT.into_response())
|
|
}
|
|
async fn destroy_all(
|
|
State(s): State<AppState>,
|
|
Extension(u): Extension<AuthUser>,
|
|
h: HeaderMap,
|
|
Path(id): Path<i64>,
|
|
) -> ResultResponse {
|
|
let all = authorize(&s, u.id, id).await?;
|
|
let mut tx = s.db.begin().await?;
|
|
verify_owner(&mut tx, u.id, id).await?;
|
|
let explicit:Vec<i64>=sqlx::query_scalar("SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user' AND shared_with_user_id IS NOT NULL").bind(id).fetch_all(&mut *tx).await?;
|
|
let all_recipients: Vec<i64> = if all && is_all(&mut tx, id).await? {
|
|
sqlx::query_scalar("SELECT id FROM users WHERE id!=? ORDER BY id")
|
|
.bind(u.id)
|
|
.fetch_all(&mut *tx)
|
|
.await?
|
|
} else {
|
|
vec![]
|
|
};
|
|
sqlx::query("DELETE FROM twofaccount_user_orders WHERE twofaccount_id=? AND (user_id IN(SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user') OR (? AND user_id!=? AND EXISTS(SELECT 1 FROM twofaccount_shares WHERE twofaccount_id=? AND scope='all_users')))").bind(id).bind(id).bind(all).bind(u.id).bind(id).execute(&mut *tx).await?;
|
|
sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND (scope='user' OR (? AND scope='all_users'))").bind(id).bind(all).execute(&mut *tx).await?;
|
|
tx.commit().await?;
|
|
notify_shares(&s, u.id, id, &explicit, Notification::Revoked, false, &h).await;
|
|
notify_shares(
|
|
&s,
|
|
u.id,
|
|
id,
|
|
&all_recipients,
|
|
Notification::Revoked,
|
|
true,
|
|
&h,
|
|
)
|
|
.await;
|
|
Ok(StatusCode::NO_CONTENT.into_response())
|
|
}
|
|
async fn share_all(
|
|
State(s): State<AppState>,
|
|
Extension(u): Extension<AuthUser>,
|
|
h: HeaderMap,
|
|
Path(id): Path<i64>,
|
|
) -> ResultResponse {
|
|
if !authorize(&s, u.id, id).await? {
|
|
return Err(ApiError(
|
|
StatusCode::FORBIDDEN,
|
|
"All Users sharing scope is disabled",
|
|
));
|
|
}
|
|
let mut tx = s.db.begin().await?;
|
|
verify_owner(&mut tx, u.id, id).await?;
|
|
sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user'")
|
|
.bind(id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
let created = !is_all(&mut tx, id).await?;
|
|
if created {
|
|
sqlx::query("INSERT INTO twofaccount_shares(twofaccount_id,scope,created_by_user_id,created_at,updated_at) VALUES(?,'all_users',?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(id).bind(u.id).execute(&mut *tx).await?;
|
|
}
|
|
tx.commit().await?;
|
|
if created {
|
|
let recipients: Vec<i64> =
|
|
sqlx::query_scalar("SELECT id FROM users WHERE id!=? ORDER BY id")
|
|
.bind(u.id)
|
|
.fetch_all(&s.db)
|
|
.await?;
|
|
notify_shares(&s, u.id, id, &recipients, Notification::Shared, true, &h).await;
|
|
}
|
|
Ok((
|
|
if created {
|
|
StatusCode::CREATED
|
|
} else {
|
|
StatusCode::OK
|
|
},
|
|
Json(json!({"twofaccount_id":id,"is_shared_with_all":true})),
|
|
)
|
|
.into_response())
|
|
}
|
|
async fn transfer(
|
|
State(s): State<AppState>,
|
|
Extension(u): Extension<AuthUser>,
|
|
h: HeaderMap,
|
|
Path(id): Path<i64>,
|
|
Json(v): Json<Value>,
|
|
) -> ResultResponse {
|
|
if !crate::auth::throttle_limit(&s.db, format!("ownership-transfer:{}", u.id), 6).await? {
|
|
return Err(ApiError(
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"Too Many Attempts.",
|
|
));
|
|
}
|
|
let all = authorize(&s, u.id, id).await?;
|
|
let Some(target) = v.get("new_owner_id").and_then(integer) else {
|
|
return Ok(invalid("new_owner_id"));
|
|
};
|
|
let Some(password) = v
|
|
.get("confirm_password")
|
|
.and_then(Value::as_str)
|
|
.filter(|v| !v.is_empty())
|
|
else {
|
|
return Ok(invalid("confirm_password"));
|
|
};
|
|
if target == u.id {
|
|
return Ok((StatusCode::UNPROCESSABLE_ENTITY,Json(json!({"message":"The new owner must not be the current owner.","errors":{"new_owner_id":["The new owner must not be the current owner."]}}))).into_response());
|
|
}
|
|
if !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id=?)")
|
|
.bind(target)
|
|
.fetch_one(&s.db)
|
|
.await?
|
|
{
|
|
return Ok(invalid("new_owner_id"));
|
|
}
|
|
let hash: String = sqlx::query_scalar("SELECT password FROM users WHERE id=?")
|
|
.bind(u.id)
|
|
.fetch_one(&s.db)
|
|
.await?;
|
|
let password = password.to_owned();
|
|
let valid = tokio::task::spawn_blocking(move || crate::auth::verify(password, hash))
|
|
.await
|
|
.unwrap_or(false);
|
|
if !valid {
|
|
return Err(ApiError(StatusCode::UNAUTHORIZED, "unauthorized"));
|
|
}
|
|
let mut tx = s.db.begin().await?;
|
|
verify_owner(&mut tx, u.id, id).await?;
|
|
// Recheck ownership under the write transaction to prevent a stale authorization transfer.
|
|
let updated = sqlx::query(
|
|
"UPDATE twofaccounts SET user_id=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?",
|
|
)
|
|
.bind(target)
|
|
.bind(id)
|
|
.bind(u.id)
|
|
.execute(&mut *tx)
|
|
.await?
|
|
.rows_affected();
|
|
if updated == 0 {
|
|
return Err(ApiError(
|
|
StatusCode::FORBIDDEN,
|
|
"This action is unauthorized.",
|
|
));
|
|
}
|
|
sqlx::query("DELETE FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user' AND shared_with_user_id=?").bind(id).bind(target).execute(&mut *tx).await?;
|
|
if !(all && is_all(&mut tx, id).await?) {
|
|
sqlx::query("DELETE FROM twofaccount_user_orders WHERE twofaccount_id=? AND user_id!=? AND user_id NOT IN(SELECT shared_with_user_id FROM twofaccount_shares WHERE twofaccount_id=? AND scope='user' AND shared_with_user_id IS NOT NULL)").bind(id).bind(target).bind(id).execute(&mut *tx).await?;
|
|
}
|
|
tx.commit().await?;
|
|
notify_shares(&s, u.id, id, &[target], Notification::Ownership, false, &h).await;
|
|
Ok(Json(json!({"twofaccount_id":id,"owner_id":target})).into_response())
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum Notification {
|
|
Shared,
|
|
Revoked,
|
|
Ownership,
|
|
}
|
|
fn notification_locale(preferences: &Value, browser: &str) -> String {
|
|
preferences["lang"]
|
|
.as_str()
|
|
.filter(|v| *v != "browser")
|
|
.unwrap_or(browser)
|
|
.to_owned()
|
|
}
|
|
fn browser_locale(headers: &HeaderMap) -> String {
|
|
let mut languages: Vec<(&str, f32)> = headers
|
|
.get("accept-language")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("")
|
|
.split(',')
|
|
.filter_map(|entry| {
|
|
let mut parts = entry.trim().split(';');
|
|
let name = parts.next()?;
|
|
let weight = parts
|
|
.next()
|
|
.and_then(|q| q.trim().strip_prefix("q="))
|
|
.and_then(|q| q.parse::<f32>().ok())
|
|
.unwrap_or(1.);
|
|
if name.is_empty() || !weight.is_finite() || weight <= 0. {
|
|
None
|
|
} else {
|
|
Some((name, weight))
|
|
}
|
|
})
|
|
.collect();
|
|
languages.sort_by(|a, b| b.1.total_cmp(&a.1));
|
|
languages
|
|
.first()
|
|
.map(|(language, _)| (*language).to_owned())
|
|
.unwrap_or_else(|| std::env::var("APP_FALLBACK_LOCALE").unwrap_or_else(|_| "en".into()))
|
|
}
|
|
fn notification_content(
|
|
kind: Notification,
|
|
all: bool,
|
|
locale: &str,
|
|
actor: &str,
|
|
label: &str,
|
|
recipient: &str,
|
|
url: &str,
|
|
) -> (String, String) {
|
|
let prefix = match kind {
|
|
Notification::Shared => "message.notifications.twofaccount_shared",
|
|
Notification::Revoked => "message.notifications.twofaccount_share_revoked",
|
|
Notification::Ownership => "message.notifications.twofaccount_ownership_transferred",
|
|
};
|
|
let replacements = [
|
|
("actor", actor),
|
|
("twofaccount", label),
|
|
("username", recipient),
|
|
];
|
|
let subject = crate::mail::translate(locale, &format!("{prefix}.subject"), &replacements);
|
|
let greeting =
|
|
crate::mail::translate(locale, "message.notifications.hello_user", &replacements);
|
|
let resume = match kind {
|
|
Notification::Ownership => format!("{prefix}.resume"),
|
|
_ => format!("{prefix}.resume_{}", if all { "all_users" } else { "user" }),
|
|
};
|
|
let mut lines = vec![
|
|
greeting,
|
|
crate::mail::translate(locale, &resume, &replacements),
|
|
];
|
|
let extra = match kind {
|
|
Notification::Shared => Some("use_it_to_generate_otp"),
|
|
Notification::Ownership => Some("check_shares"),
|
|
Notification::Revoked => None,
|
|
};
|
|
if let Some(extra) = extra {
|
|
lines.push(crate::mail::translate(
|
|
locale,
|
|
&format!("{prefix}.{extra}"),
|
|
&replacements,
|
|
));
|
|
}
|
|
lines.push(format!(
|
|
"{}: {url}",
|
|
crate::mail::translate(locale, "link.go_to_2fauth_host", &replacements)
|
|
));
|
|
(subject, lines.join("\n\n"))
|
|
}
|
|
async fn notify_shares(
|
|
s: &AppState,
|
|
actor: i64,
|
|
account: i64,
|
|
recipients: &[i64],
|
|
kind: Notification,
|
|
all: bool,
|
|
headers: &HeaderMap,
|
|
) {
|
|
if recipients.is_empty() {
|
|
return;
|
|
}
|
|
match prepare_notifications(s, actor, account, recipients, kind, all, headers).await {
|
|
Ok(deliveries) => {
|
|
for (email, subject, body) in deliveries {
|
|
if crate::mail::notify(&email, &subject, &body).await.is_err() {
|
|
// Upstream catches all delivery exceptions: the sharing action remains committed.
|
|
tracing::warn!("Sharing notification delivery failed");
|
|
}
|
|
}
|
|
}
|
|
Err(_) => tracing::warn!("Sharing notification could not be prepared"),
|
|
}
|
|
}
|
|
|
|
async fn prepare_notifications(
|
|
s: &AppState,
|
|
actor: i64,
|
|
account: i64,
|
|
recipients: &[i64],
|
|
kind: Notification,
|
|
all: bool,
|
|
headers: &HeaderMap,
|
|
) -> Result<Vec<(String, String, String)>, ApiError> {
|
|
// Select only printable account fields. OTP secrets never enter notification content.
|
|
let row = sqlx::query("SELECT service,account FROM twofaccounts WHERE id=?")
|
|
.bind(account)
|
|
.fetch_one(&s.db)
|
|
.await?;
|
|
let read = |field: &str| -> Result<String, ApiError> {
|
|
let text = row.try_get::<Option<String>, _>(field)?.unwrap_or_default();
|
|
if s.encryption_enabled() && !text.is_empty() {
|
|
s.cipher
|
|
.as_ref()
|
|
.ok_or(ApiError(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Encrypted notification label unavailable",
|
|
))?
|
|
.decrypt(&text)
|
|
.map_err(|_| {
|
|
ApiError(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"Encrypted notification label unavailable",
|
|
)
|
|
})
|
|
} else {
|
|
Ok(text)
|
|
}
|
|
};
|
|
let service = read("service")?;
|
|
let name = read("account")?;
|
|
let label = if service.trim().is_empty() {
|
|
name.trim().to_owned()
|
|
} else {
|
|
format!("{} ({})", service.trim(), name.trim())
|
|
};
|
|
let actor = sqlx::query("SELECT name,preferences FROM users WHERE id=?")
|
|
.bind(actor)
|
|
.fetch_one(&s.db)
|
|
.await?;
|
|
let actor_name: String = actor.try_get("name")?;
|
|
let prefs = crate::settings::preferences_from(
|
|
actor
|
|
.try_get::<Option<String>, _>("preferences")?
|
|
.as_deref(),
|
|
);
|
|
let locale = notification_locale(&prefs, &browser_locale(headers));
|
|
let users = sqlx::query("SELECT id,name,email,preferences FROM users ORDER BY id")
|
|
.fetch_all(&s.db)
|
|
.await?;
|
|
let targets: HashSet<_> = recipients.iter().copied().collect();
|
|
let host = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost".into());
|
|
let mut deliveries = vec![];
|
|
for recipient in users {
|
|
if !targets.contains(&recipient.try_get::<i64, _>("id")?) {
|
|
continue;
|
|
}
|
|
let prefs = crate::settings::preferences_from(
|
|
recipient
|
|
.try_get::<Option<String>, _>("preferences")?
|
|
.as_deref(),
|
|
);
|
|
let preference = match kind {
|
|
Notification::Ownership => "notifyOnOwnershipTransfer",
|
|
_ => "notifyOnShare",
|
|
};
|
|
if !crate::settings::php_bool(&prefs[preference]) {
|
|
continue;
|
|
}
|
|
let locale = notification_locale(&prefs, &locale);
|
|
let (subject, body) = notification_content(
|
|
kind,
|
|
all,
|
|
&locale,
|
|
&actor_name,
|
|
&label,
|
|
recipient.try_get("name")?,
|
|
&host,
|
|
);
|
|
deliveries.push((recipient.try_get("email")?, subject, body));
|
|
}
|
|
Ok(deliveries)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::{
|
|
body::{Body, to_bytes},
|
|
http::Request,
|
|
};
|
|
use tower::ServiceExt;
|
|
async fn setup() -> AppState {
|
|
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.connect("sqlite::memory:")
|
|
.await
|
|
.unwrap();
|
|
crate::db::init(&db).await.unwrap();
|
|
crate::auth::init(&db).await.unwrap();
|
|
sqlx::raw_sql("INSERT INTO users(id,name,email,password) VALUES(1,'Alice','alice@example.org','unused'),(2,'Bob','bob@example.org','unused'),(3,'Carol','carol@example.org','unused'); INSERT INTO options(key,value) VALUES('enableSharing','{{1}}'),('enableAllUsersSharingScope','{{1}}'); INSERT INTO twofaccounts(id,user_id,account,otp_type,secret,algorithm,digits,counter) VALUES(1,1,'alice','hotp','GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ','sha1',6,0);").execute(&db).await.unwrap();
|
|
sqlx::query("UPDATE users SET preferences='{\"notifyOnShare\":false,\"notifyOnOwnershipTransfer\":false}'").execute(&db).await.unwrap();
|
|
AppState {
|
|
db,
|
|
cipher: None,
|
|
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
|
}
|
|
}
|
|
async fn request(
|
|
s: &AppState,
|
|
user: i64,
|
|
method: &str,
|
|
url: &str,
|
|
v: Value,
|
|
) -> (StatusCode, Value) {
|
|
let app = routes()
|
|
.merge(crate::accounts::routes())
|
|
.merge(crate::groups::routes())
|
|
.layer(Extension(AuthUser {
|
|
id: user,
|
|
is_admin: false,
|
|
}))
|
|
.with_state(s.clone());
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(method)
|
|
.uri(url)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(v.to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
|
|
(
|
|
status,
|
|
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
|
)
|
|
}
|
|
#[tokio::test]
|
|
async fn notification_targets_respect_preferences_locales_scope_and_hide_secrets() {
|
|
let mut s = setup().await;
|
|
sqlx::query("UPDATE users SET preferences=? WHERE id=2")
|
|
.bind(
|
|
json!({"notifyOnShare":true,"notifyOnOwnershipTransfer":false,"lang":"fr"})
|
|
.to_string(),
|
|
)
|
|
.execute(&s.db)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("UPDATE users SET preferences=? WHERE id=3")
|
|
.bind(
|
|
json!({"notifyOnShare":false,"notifyOnOwnershipTransfer":true,"lang":"en"})
|
|
.to_string(),
|
|
)
|
|
.execute(&s.db)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("UPDATE twofaccounts SET service=' Example ',account=' alice@example.org '")
|
|
.execute(&s.db)
|
|
.await
|
|
.unwrap();
|
|
let h = HeaderMap::new();
|
|
let deliveries =
|
|
prepare_notifications(&s, 1, 1, &[2, 3, 2], Notification::Shared, false, &h)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(deliveries.len(), 1);
|
|
assert_eq!(deliveries[0].0, "bob@example.org");
|
|
assert_eq!(
|
|
deliveries[0].1,
|
|
crate::mail::translate(
|
|
"fr",
|
|
"message.notifications.twofaccount_shared.subject",
|
|
&[]
|
|
)
|
|
);
|
|
assert!(deliveries[0].2.contains("Example (alice@example.org)"));
|
|
assert!(deliveries[0].2.contains("Alice"));
|
|
assert!(!deliveries[0].2.contains("GEZDGNBV"));
|
|
let ownership =
|
|
prepare_notifications(&s, 1, 1, &[2, 3], Notification::Ownership, false, &h)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(ownership.len(), 1);
|
|
assert_eq!(ownership[0].0, "carol@example.org");
|
|
assert!(ownership[0].2.contains("review"));
|
|
let (subject, body) = notification_content(
|
|
Notification::Revoked,
|
|
true,
|
|
"en",
|
|
"Alice",
|
|
"Account",
|
|
"Bob",
|
|
"https://example.org",
|
|
);
|
|
assert_eq!(subject, "2FA account sharing revoked");
|
|
assert!(body.contains("for all users"));
|
|
assert!(body.contains("https://example.org"));
|
|
assert!(!body.contains("You can now"));
|
|
assert!(
|
|
prepare_notifications(&s, 1, 1, &[1], Notification::Shared, false, &h)
|
|
.await
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
s.cipher = Some(std::sync::Arc::new(
|
|
crate::crypto::LaravelCipher::from_app_key(
|
|
"base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
)
|
|
.unwrap(),
|
|
));
|
|
s.encrypt_db
|
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
let cipher = s.cipher.as_ref().unwrap();
|
|
sqlx::query("UPDATE twofaccounts SET account=?,service=?,secret='must-never-be-read'")
|
|
.bind(cipher.encrypt("hidden@example.org").unwrap())
|
|
.bind(cipher.encrypt("Encrypted Label").unwrap())
|
|
.execute(&s.db)
|
|
.await
|
|
.unwrap();
|
|
let encrypted = prepare_notifications(&s, 1, 1, &[2], Notification::Shared, false, &h)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
encrypted[0]
|
|
.2
|
|
.contains("Encrypted Label (hidden@example.org)")
|
|
);
|
|
assert!(!encrypted[0].2.contains("must-never-be-read"));
|
|
}
|
|
#[tokio::test]
|
|
async fn explicit_share_is_idempotent_and_read_only_with_independent_groups() {
|
|
let s = setup().await;
|
|
let (status, v) = request(
|
|
&s,
|
|
1,
|
|
"POST",
|
|
"/twofaccounts/1/shares",
|
|
json!({"user_ids":[2]}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
assert_eq!(v["specific_users"][0]["is_shared_with"], true);
|
|
assert_eq!(
|
|
request(
|
|
&s,
|
|
1,
|
|
"POST",
|
|
"/twofaccounts/1/shares",
|
|
json!({"user_id":"2"})
|
|
)
|
|
.await
|
|
.0,
|
|
StatusCode::OK
|
|
);
|
|
let (_, v) = request(&s, 2, "GET", "/twofaccounts?withSecret=true", Value::Null).await;
|
|
assert_eq!(v[0]["is_borrowed"], true);
|
|
assert_eq!(v[0]["borrowed_by"], "Alice");
|
|
assert!(v[0].get("secret").is_none());
|
|
let (_, v) = request(&s, 2, "GET", "/twofaccounts/1?withSecret=true", Value::Null).await;
|
|
assert!(v.get("secret").is_none());
|
|
assert_eq!(
|
|
request(&s, 2, "GET", "/twofaccounts/1/otp", Value::Null)
|
|
.await
|
|
.1["password"],
|
|
"755224"
|
|
);
|
|
assert_eq!(
|
|
request(&s, 2, "GET", "/twofaccounts/1/otp-logs", Value::Null)
|
|
.await
|
|
.0,
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
let (_, logs) = request(
|
|
&s,
|
|
1,
|
|
"GET",
|
|
"/twofaccounts/1/otp-logs?limit=1",
|
|
Value::Null,
|
|
)
|
|
.await;
|
|
assert_eq!(logs[0]["requester_name"], "Bob");
|
|
assert_eq!(logs[0]["owner_name"], "Alice");
|
|
assert_eq!(logs[0]["counter"], 1);
|
|
assert!(logs[0].get("secret").is_none());
|
|
assert_eq!(
|
|
request(&s, 2, "GET", "/twofaccounts/export?ids=1", Value::Null)
|
|
.await
|
|
.0,
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
assert_eq!(
|
|
request(&s, 2, "DELETE", "/twofaccounts/1", Value::Null)
|
|
.await
|
|
.0,
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
assert_eq!(
|
|
request(&s, 2, "DELETE", "/twofaccounts/1/shares", Value::Null)
|
|
.await
|
|
.0,
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
let (_, group) = request(&s, 2, "POST", "/groups", json!({"name":"Borrowed"})).await;
|
|
let group = group["id"].as_i64().unwrap();
|
|
assert_eq!(
|
|
request(
|
|
&s,
|
|
2,
|
|
"POST",
|
|
&format!("/groups/{group}/assign"),
|
|
json!({"ids":[1]})
|
|
)
|
|
.await
|
|
.0,
|
|
StatusCode::OK
|
|
);
|
|
assert_eq!(
|
|
request(&s, 2, "GET", "/twofaccounts/1", Value::Null)
|
|
.await
|
|
.1["group_id"],
|
|
group
|
|
);
|
|
assert!(
|
|
request(&s, 1, "GET", "/twofaccounts/1", Value::Null)
|
|
.await
|
|
.1["group_id"]
|
|
.is_null()
|
|
);
|
|
assert_eq!(
|
|
request(&s, 2, "GET", "/twofaccounts/count", Value::Null)
|
|
.await
|
|
.1,
|
|
json!({"owned":0,"shared":1,"total":1})
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
&s,
|
|
2,
|
|
"POST",
|
|
"/twofaccounts/reorder",
|
|
json!({"orderedIds":[1]})
|
|
)
|
|
.await
|
|
.0,
|
|
StatusCode::OK
|
|
);
|
|
request(&s, 1, "DELETE", "/twofaccounts/1/shares/2", Value::Null).await;
|
|
let remaining: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM twofaccount_user_orders WHERE user_id=2")
|
|
.fetch_one(&s.db)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(remaining, 0);
|
|
assert_eq!(
|
|
request(&s, 2, "GET", "/twofaccounts", Value::Null).await.1,
|
|
json!([])
|
|
);
|
|
assert_eq!(
|
|
request(&s, 2, "GET", &format!("/groups/{group}"), Value::Null)
|
|
.await
|
|
.1["twofaccounts_count"],
|
|
0
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
async fn all_scope_conflict_disablement_and_batch_validation_are_atomic() {
|
|
let s = setup().await;
|
|
assert_eq!(
|
|
request(
|
|
&s,
|
|
1,
|
|
"POST",
|
|
"/twofaccounts/1/shares",
|
|
json!({"user_ids":[2,999]})
|
|
)
|
|
.await
|
|
.0,
|
|
StatusCode::UNPROCESSABLE_ENTITY
|
|
);
|
|
assert!(!can_access(&s.db, 2, 1).await.unwrap());
|
|
assert_eq!(
|
|
request(&s, 1, "POST", "/twofaccounts/1/shares/all", json!({}))
|
|
.await
|
|
.0,
|
|
StatusCode::CREATED
|
|
);
|
|
assert_eq!(
|
|
request(&s, 1, "POST", "/twofaccounts/1/shares/all", json!({}))
|
|
.await
|
|
.0,
|
|
StatusCode::OK
|
|
);
|
|
assert!(can_access(&s.db, 3, 1).await.unwrap());
|
|
assert_eq!(
|
|
request(
|
|
&s,
|
|
1,
|
|
"POST",
|
|
"/twofaccounts/1/shares",
|
|
json!({"user_ids":[2]})
|
|
)
|
|
.await
|
|
.0,
|
|
StatusCode::CONFLICT
|
|
);
|
|
sqlx::query("UPDATE options SET value='{{}}' WHERE key='enableAllUsersSharingScope'")
|
|
.execute(&s.db)
|
|
.await
|
|
.unwrap();
|
|
assert!(!can_access(&s.db, 3, 1).await.unwrap());
|
|
assert_eq!(
|
|
request(
|
|
&s,
|
|
1,
|
|
"POST",
|
|
"/twofaccounts/1/shares",
|
|
json!({"user_ids":[2]})
|
|
)
|
|
.await
|
|
.0,
|
|
StatusCode::CREATED
|
|
);
|
|
assert!(can_access(&s.db, 2, 1).await.unwrap());
|
|
let count: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM twofaccount_shares WHERE scope='all_users'")
|
|
.fetch_one(&s.db)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(count, 0);
|
|
sqlx::query("UPDATE options SET value='{{}}' WHERE key='enableSharing'")
|
|
.execute(&s.db)
|
|
.await
|
|
.unwrap();
|
|
assert!(!can_access(&s.db, 2, 1).await.unwrap());
|
|
assert_eq!(
|
|
request(&s, 1, "GET", "/twofaccounts/1/shares", Value::Null)
|
|
.await
|
|
.0,
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
async fn transfer_requires_password_removes_new_owner_share_and_preserves_others() {
|
|
let s = setup().await;
|
|
let hash = bcrypt::hash("password", 4).unwrap();
|
|
sqlx::query("UPDATE users SET password=? WHERE id=1")
|
|
.bind(hash)
|
|
.execute(&s.db)
|
|
.await
|
|
.unwrap();
|
|
request(
|
|
&s,
|
|
1,
|
|
"POST",
|
|
"/twofaccounts/1/shares",
|
|
json!({"user_ids":[2,3]}),
|
|
)
|
|
.await;
|
|
sqlx::query("INSERT INTO twofaccount_user_orders(user_id,twofaccount_id,position) VALUES(1,1,1),(2,1,1),(3,1,1)").execute(&s.db).await.unwrap();
|
|
assert_eq!(
|
|
request(
|
|
&s,
|
|
1,
|
|
"PATCH",
|
|
"/twofaccounts/1/owner",
|
|
json!({"new_owner_id":2,"confirm_password":"wrong"})
|
|
)
|
|
.await
|
|
.0,
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
&s,
|
|
1,
|
|
"PATCH",
|
|
"/twofaccounts/1/owner",
|
|
json!({"new_owner_id":2,"confirm_password":"password"})
|
|
)
|
|
.await
|
|
.0,
|
|
StatusCode::OK
|
|
);
|
|
let (_, v) = request(&s, 2, "GET", "/twofaccounts/1", Value::Null).await;
|
|
assert!(v.get("secret").is_some());
|
|
assert!(!can_access(&s.db, 1, 1).await.unwrap());
|
|
assert!(can_access(&s.db, 3, 1).await.unwrap());
|
|
let ids: Vec<i64> =
|
|
sqlx::query_scalar("SELECT shared_with_user_id FROM twofaccount_shares")
|
|
.fetch_all(&s.db)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(ids, vec![3]);
|
|
let orders: Vec<i64> =
|
|
sqlx::query_scalar("SELECT user_id FROM twofaccount_user_orders ORDER BY user_id")
|
|
.fetch_all(&s.db)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(orders, vec![2, 3]);
|
|
}
|
|
}
|