mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-10 06:10:09 +00:00
Actor preferences
This commit is contained in:
5
src/api/actor/mod.rs
Normal file
5
src/api/actor/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod preferences;
|
||||
mod profile;
|
||||
|
||||
pub use preferences::{get_preferences, put_preferences};
|
||||
pub use profile::{get_profile, get_profiles};
|
||||
233
src/api/actor/preferences.rs
Normal file
233
src/api/actor/preferences.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const APP_BSKY_NAMESPACE: &str = "app.bsky";
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GetPreferencesOutput {
|
||||
pub preferences: Vec<Value>,
|
||||
}
|
||||
|
||||
pub async fn get_preferences(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let user_id: uuid::Uuid =
|
||||
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let prefs_result = sqlx::query!(
|
||||
"SELECT name, value_json FROM account_preferences WHERE user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
|
||||
let prefs = match prefs_result {
|
||||
Ok(rows) => rows,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to fetch preferences"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let preferences: Vec<Value> = prefs
|
||||
.into_iter()
|
||||
.filter(|row| {
|
||||
row.name == APP_BSKY_NAMESPACE || row.name.starts_with(&format!("{}.", APP_BSKY_NAMESPACE))
|
||||
})
|
||||
.filter_map(|row| {
|
||||
if row.name == "app.bsky.actor.defs#declaredAgePref" {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_value(row.value_json).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
(StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PutPreferencesInput {
|
||||
pub preferences: Vec<Value>,
|
||||
}
|
||||
|
||||
pub async fn put_preferences(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(input): Json<PutPreferencesInput>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let user_id: uuid::Uuid =
|
||||
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
for pref in &input.preferences {
|
||||
let pref_type = match pref.get("$type").and_then(|t| t.as_str()) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Preference missing $type field"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !pref_type.starts_with(APP_BSKY_NAMESPACE) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": format!("Invalid preference namespace: {}", pref_type)})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if pref_type == "app.bsky.actor.defs#declaredAgePref" {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "declaredAgePref is read-only"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to start transaction"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let delete_result = sqlx::query!(
|
||||
"DELETE FROM account_preferences WHERE user_id = $1 AND (name = $2 OR name LIKE $3)",
|
||||
user_id,
|
||||
APP_BSKY_NAMESPACE,
|
||||
format!("{}.%", APP_BSKY_NAMESPACE)
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
if delete_result.is_err() {
|
||||
let _ = tx.rollback().await;
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to clear preferences"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
for pref in input.preferences {
|
||||
let pref_type = pref.get("$type").and_then(|t| t.as_str()).unwrap();
|
||||
|
||||
let insert_result = sqlx::query!(
|
||||
"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)",
|
||||
user_id,
|
||||
pref_type,
|
||||
pref
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
if insert_result.is_err() {
|
||||
let _ = tx.rollback().await;
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to save preference"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(_) = tx.commit().await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to commit transaction"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
206
src/api/actor/profile.rs
Normal file
206
src/api/actor/profile.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetProfileParams {
|
||||
pub actor: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct GetProfilesParams {
|
||||
pub actors: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileViewDetailed {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub banner: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct GetProfilesOutput {
|
||||
pub profiles: Vec<ProfileViewDetailed>,
|
||||
}
|
||||
|
||||
async fn get_local_profile_record(state: &AppState, did: &str) -> Option<Value> {
|
||||
let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()??;
|
||||
|
||||
let record_row = sqlx::query!(
|
||||
"SELECT record_cid FROM records WHERE repo_id = $1 AND collection = 'app.bsky.actor.profile' AND rkey = 'self'",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()??;
|
||||
|
||||
let cid: cid::Cid = record_row.record_cid.parse().ok()?;
|
||||
let block_bytes = state.block_store.get(&cid).await.ok()??;
|
||||
serde_ipld_dagcbor::from_slice(&block_bytes).ok()
|
||||
}
|
||||
|
||||
fn munge_profile_with_local(profile: &mut ProfileViewDetailed, local_record: &Value) {
|
||||
if let Some(display_name) = local_record.get("displayName").and_then(|v| v.as_str()) {
|
||||
profile.display_name = Some(display_name.to_string());
|
||||
}
|
||||
if let Some(description) = local_record.get("description").and_then(|v| v.as_str()) {
|
||||
profile.description = Some(description.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
async fn proxy_to_appview(
|
||||
method: &str,
|
||||
params: &HashMap<String, String>,
|
||||
auth_header: Option<&str>,
|
||||
) -> Result<(StatusCode, Value), Response> {
|
||||
let appview_url = match std::env::var("APPVIEW_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return Err(
|
||||
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "No upstream AppView configured"}))).into_response()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let target_url = format!("{}/xrpc/{}", appview_url, method);
|
||||
info!("Proxying GET request to {}", target_url);
|
||||
|
||||
let client = Client::new();
|
||||
let mut request_builder = client.get(&target_url).query(params);
|
||||
|
||||
if let Some(auth) = auth_header {
|
||||
request_builder = request_builder.header("Authorization", auth);
|
||||
}
|
||||
|
||||
match request_builder.send().await {
|
||||
Ok(resp) => {
|
||||
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
match resp.json::<Value>().await {
|
||||
Ok(body) => Ok((status, body)),
|
||||
Err(e) => {
|
||||
error!("Error parsing proxy response: {:?}", e);
|
||||
Err((StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error sending proxy request: {:?}", e);
|
||||
if e.is_timeout() {
|
||||
Err((StatusCode::GATEWAY_TIMEOUT, Json(json!({"error": "UpstreamTimeout"}))).into_response())
|
||||
} else {
|
||||
Err((StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<GetProfileParams>,
|
||||
) -> Response {
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
|
||||
let auth_did = auth_header.and_then(|h| {
|
||||
let token = crate::auth::extract_bearer_token_from_header(Some(h))?;
|
||||
crate::auth::get_did_from_token(&token).ok()
|
||||
});
|
||||
|
||||
let mut query_params = HashMap::new();
|
||||
query_params.insert("actor".to_string(), params.actor.clone());
|
||||
|
||||
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfile", &query_params, auth_header).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if !status.is_success() {
|
||||
return (status, Json(body)).into_response();
|
||||
}
|
||||
|
||||
let mut profile: ProfileViewDetailed = match serde_json::from_value(body) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profile response"}))).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref did) = auth_did {
|
||||
if profile.did == *did {
|
||||
if let Some(local_record) = get_local_profile_record(&state, did).await {
|
||||
munge_profile_with_local(&mut profile, &local_record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(profile)).into_response()
|
||||
}
|
||||
|
||||
pub async fn get_profiles(
|
||||
State(state): State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<GetProfilesParams>,
|
||||
) -> Response {
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
|
||||
let auth_did = auth_header.and_then(|h| {
|
||||
let token = crate::auth::extract_bearer_token_from_header(Some(h))?;
|
||||
crate::auth::get_did_from_token(&token).ok()
|
||||
});
|
||||
|
||||
let mut query_params = HashMap::new();
|
||||
query_params.insert("actors".to_string(), params.actors.clone());
|
||||
|
||||
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfiles", &query_params, auth_header).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if !status.is_success() {
|
||||
return (status, Json(body)).into_response();
|
||||
}
|
||||
|
||||
let mut output: GetProfilesOutput = match serde_json::from_value(body) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profiles response"}))).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref did) = auth_did {
|
||||
for profile in &mut output.profiles {
|
||||
if profile.did == *did {
|
||||
if let Some(local_record) = get_local_profile_record(&state, did).await {
|
||||
munge_profile_with_local(profile, &local_record);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(output)).into_response()
|
||||
}
|
||||
@@ -415,16 +415,8 @@ pub async fn create_account(
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::notifications::enqueue_welcome_email(
|
||||
&state.db,
|
||||
user_id,
|
||||
&input.email,
|
||||
&input.handle,
|
||||
&hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to enqueue welcome email: {:?}", e);
|
||||
if let Err(e) = crate::notifications::enqueue_welcome(&state.db, user_id, &hostname).await {
|
||||
warn!("Failed to enqueue welcome notification: {:?}", e);
|
||||
}
|
||||
|
||||
(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod actor;
|
||||
pub mod admin;
|
||||
pub mod feed;
|
||||
pub mod identity;
|
||||
|
||||
@@ -247,11 +247,11 @@ pub async fn request_account_delete(
|
||||
}
|
||||
};
|
||||
|
||||
let user = match sqlx::query!("SELECT id, email, handle FROM users WHERE did = $1", did)
|
||||
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(Some(id)) => id,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -260,9 +260,6 @@ pub async fn request_account_delete(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let user_id = user.id;
|
||||
let email = user.email;
|
||||
let handle = user.handle;
|
||||
|
||||
let confirmation_token = Uuid::new_v4().to_string();
|
||||
let expires_at = Utc::now() + Duration::minutes(15);
|
||||
@@ -286,15 +283,8 @@ pub async fn request_account_delete(
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::notifications::enqueue_account_deletion(
|
||||
&state.db,
|
||||
user_id,
|
||||
&email,
|
||||
&handle,
|
||||
&confirmation_token,
|
||||
&hostname,
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
crate::notifications::enqueue_account_deletion(&state.db, user_id, &confirmation_token, &hostname).await
|
||||
{
|
||||
warn!("Failed to enqueue account deletion notification: {:?}", e);
|
||||
}
|
||||
|
||||
@@ -38,15 +38,12 @@ pub async fn request_password_reset(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, handle FROM users WHERE LOWER(email) = $1",
|
||||
email
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let user = sqlx::query!("SELECT id FROM users WHERE LOWER(email) = $1", email)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
let (user_id, handle) = match user {
|
||||
Ok(Some(row)) => (row.id, row.handle),
|
||||
let user_id = match user {
|
||||
Ok(Some(row)) => row.id,
|
||||
Ok(None) => {
|
||||
info!("Password reset requested for unknown email: {}", email);
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
@@ -83,15 +80,8 @@ pub async fn request_password_reset(
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::notifications::enqueue_password_reset(
|
||||
&state.db,
|
||||
user_id,
|
||||
&email,
|
||||
&handle,
|
||||
&code,
|
||||
&hostname,
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
crate::notifications::enqueue_password_reset(&state.db, user_id, &code, &hostname).await
|
||||
{
|
||||
warn!("Failed to enqueue password reset notification: {:?}", e);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#[allow(deprecated)]
|
||||
use aes_gcm::{
|
||||
Aes256Gcm, KeyInit, Nonce,
|
||||
aead::Aead,
|
||||
@@ -127,6 +128,7 @@ impl AuthConfig {
|
||||
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
#[allow(deprecated)]
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
|
||||
let ciphertext = cipher
|
||||
@@ -148,6 +150,7 @@ impl AuthConfig {
|
||||
let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key)
|
||||
.map_err(|e| format!("Failed to create cipher: {}", e))?;
|
||||
|
||||
#[allow(deprecated)]
|
||||
let nonce = Nonce::from_slice(&encrypted[..12]);
|
||||
let ciphertext = &encrypted[12..];
|
||||
|
||||
|
||||
16
src/lib.rs
16
src/lib.rs
@@ -262,6 +262,22 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.atproto.admin.sendEmail",
|
||||
post(api::admin::send_email),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/app.bsky.actor.getPreferences",
|
||||
get(api::actor::get_preferences),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/app.bsky.actor.putPreferences",
|
||||
post(api::actor::put_preferences),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/app.bsky.actor.getProfile",
|
||||
get(api::actor::get_profile),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/app.bsky.actor.getProfiles",
|
||||
get(api::actor::get_profiles),
|
||||
)
|
||||
// I know I know, I'm not supposed to implement appview endpoints. Leave me be
|
||||
.route(
|
||||
"/xrpc/app.bsky.feed.getTimeline",
|
||||
|
||||
@@ -5,7 +5,7 @@ mod types;
|
||||
pub use sender::{EmailSender, NotificationSender};
|
||||
pub use service::{
|
||||
enqueue_account_deletion, enqueue_email_update, enqueue_email_verification,
|
||||
enqueue_notification, enqueue_password_reset, enqueue_welcome_email, NotificationService,
|
||||
enqueue_notification, enqueue_password_reset, enqueue_welcome, NotificationService,
|
||||
};
|
||||
pub use types::{
|
||||
NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
|
||||
|
||||
@@ -254,25 +254,57 @@ pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) ->
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn enqueue_welcome_email(
|
||||
pub struct UserNotificationPrefs {
|
||||
pub channel: NotificationChannel,
|
||||
pub email: String,
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub async fn get_user_notification_prefs(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<UserNotificationPrefs, sqlx::Error> {
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
email,
|
||||
handle,
|
||||
preferred_notification_channel as "channel: NotificationChannel"
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
user_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
Ok(UserNotificationPrefs {
|
||||
channel: row.channel,
|
||||
email: row.email,
|
||||
handle: row.handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn enqueue_welcome(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
email: &str,
|
||||
handle: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_notification_prefs(db, user_id).await?;
|
||||
|
||||
let body = format!(
|
||||
"Welcome to {}!\n\nYour handle is: @{}\n\nThank you for joining us.",
|
||||
hostname, handle
|
||||
hostname, prefs.handle
|
||||
);
|
||||
|
||||
enqueue_notification(
|
||||
db,
|
||||
NewNotification::email(
|
||||
NewNotification::new(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::Welcome,
|
||||
email.to_string(),
|
||||
format!("Welcome to {}", hostname),
|
||||
prefs.email.clone(),
|
||||
Some(format!("Welcome to {}", hostname)),
|
||||
body,
|
||||
),
|
||||
)
|
||||
@@ -308,23 +340,24 @@ pub async fn enqueue_email_verification(
|
||||
pub async fn enqueue_password_reset(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
email: &str,
|
||||
handle: &str,
|
||||
code: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_notification_prefs(db, user_id).await?;
|
||||
|
||||
let body = format!(
|
||||
"Hello @{},\n\nYour password reset code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.",
|
||||
handle, code
|
||||
"Hello @{},\n\nYour password reset code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.",
|
||||
prefs.handle, code
|
||||
);
|
||||
|
||||
enqueue_notification(
|
||||
db,
|
||||
NewNotification::email(
|
||||
NewNotification::new(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::PasswordReset,
|
||||
email.to_string(),
|
||||
format!("Password Reset - {}", hostname),
|
||||
prefs.email.clone(),
|
||||
Some(format!("Password Reset - {}", hostname)),
|
||||
body,
|
||||
),
|
||||
)
|
||||
@@ -360,23 +393,24 @@ pub async fn enqueue_email_update(
|
||||
pub async fn enqueue_account_deletion(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
email: &str,
|
||||
handle: &str,
|
||||
code: &str,
|
||||
hostname: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_notification_prefs(db, user_id).await?;
|
||||
|
||||
let body = format!(
|
||||
"Hello @{},\n\nYour account deletion confirmation code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.",
|
||||
handle, code
|
||||
prefs.handle, code
|
||||
);
|
||||
|
||||
enqueue_notification(
|
||||
db,
|
||||
NewNotification::email(
|
||||
NewNotification::new(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::AccountDeletion,
|
||||
email.to_string(),
|
||||
format!("Account Deletion Request - {}", hostname),
|
||||
prefs.email.clone(),
|
||||
Some(format!("Account Deletion Request - {}", hostname)),
|
||||
body,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -63,6 +63,25 @@ pub struct NewNotification {
|
||||
}
|
||||
|
||||
impl NewNotification {
|
||||
pub fn new(
|
||||
user_id: Uuid,
|
||||
channel: NotificationChannel,
|
||||
notification_type: NotificationType,
|
||||
recipient: String,
|
||||
subject: Option<String>,
|
||||
body: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
user_id,
|
||||
channel,
|
||||
notification_type,
|
||||
recipient,
|
||||
subject,
|
||||
body,
|
||||
metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn email(
|
||||
user_id: Uuid,
|
||||
notification_type: NotificationType,
|
||||
@@ -70,14 +89,13 @@ impl NewNotification {
|
||||
subject: String,
|
||||
body: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
Self::new(
|
||||
user_id,
|
||||
channel: NotificationChannel::Email,
|
||||
NotificationChannel::Email,
|
||||
notification_type,
|
||||
recipient,
|
||||
subject: Some(subject),
|
||||
Some(subject),
|
||||
body,
|
||||
metadata: None,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user