Files
tranquil-pds/crates/tranquil-api/src/actor/preferences.rs
T
isabelandTangled baef2be844 feat: allow max prefs to be configured
some people (me) have a large amount of prefs and moving from ref pds to
tranquil ment i lost a few of these. so in this pr i bumped the max
prefs to 1000 and made it configurable via the config
2026-04-16 19:45:17 +03:00

202 lines
6.7 KiB
Rust

use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use chrono::{Datelike, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Auth, NotTakendown, Permissive};
use tranquil_pds::state::AppState;
const APP_BSKY_NAMESPACE: &str = "app.bsky";
const MAX_PREFERENCE_SIZE: usize = 10_000;
const PERSONAL_DETAILS_PREF: &str = "app.bsky.actor.defs#personalDetailsPref";
const DECLARED_AGE_PREF: &str = "app.bsky.actor.defs#declaredAgePref";
fn get_age_from_datestring(birth_date: &str) -> Option<i32> {
let bday = NaiveDate::parse_from_str(birth_date, "%Y-%m-%d").ok()?;
let today = Utc::now().date_naive();
let mut age = today.year() - bday.year();
let m = i32::try_from(today.month()).unwrap_or(0) - i32::try_from(bday.month()).unwrap_or(0);
if m < 0 || (m == 0 && today.day() < bday.day()) {
age -= 1;
}
Some(age)
}
#[derive(Serialize)]
pub struct GetPreferencesOutput {
pub preferences: Vec<Value>,
}
pub async fn get_preferences(State(state): State<AppState>, auth: Auth<Permissive>) -> Response {
let has_full_access = auth.permissions().has_full_access();
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
Ok(Some(id)) => id,
_ => {
return ApiError::InternalError(Some("User not found".into())).into_response();
}
};
let prefs = match state.repos.infra.get_account_preferences(user_id).await {
Ok(rows) => rows,
Err(_) => {
return ApiError::InternalError(Some("Failed to fetch preferences".into()))
.into_response();
}
};
let mut personal_details_pref: Option<Value> = None;
let mut preferences: Vec<Value> = prefs
.into_iter()
.filter(|(name, _)| {
name == APP_BSKY_NAMESPACE || name.starts_with(&format!("{}.", APP_BSKY_NAMESPACE))
})
.filter_map(|(name, value_json)| {
if name == DECLARED_AGE_PREF {
return None;
}
if name == PERSONAL_DETAILS_PREF {
if !has_full_access {
return None;
}
personal_details_pref = serde_json::from_value(value_json.clone()).ok();
}
serde_json::from_value(value_json).ok()
})
.collect();
if let Some(age) = personal_details_pref
.as_ref()
.and_then(|pref| pref.get("birthDate"))
.and_then(Value::as_str)
.and_then(get_age_from_datestring)
{
let declared_age_pref = serde_json::json!({
"$type": DECLARED_AGE_PREF,
"isOverAge13": age >= 13,
"isOverAge16": age >= 16,
"isOverAge18": age >= 18,
});
preferences.push(declared_age_pref);
}
(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>,
auth: Auth<NotTakendown>,
Json(input): Json<PutPreferencesInput>,
) -> Response {
let max_preferences_count: usize = tranquil_config::get().server.max_preferences_count;
let has_full_access = auth.permissions().has_full_access();
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
Ok(Some(id)) => id,
_ => {
return ApiError::InternalError(Some("User not found".into())).into_response();
}
};
if input.preferences.len() > max_preferences_count {
return ApiError::InvalidRequest(format!(
"Too many preferences: {} exceeds limit of {}",
input.preferences.len(),
max_preferences_count
))
.into_response();
}
enum PrefValidation {
Ok(Option<String>),
TooLarge(usize),
MissingType,
WrongNamespace,
}
let validation_results: Vec<PrefValidation> = input
.preferences
.iter()
.map(|pref| {
let pref_str = serde_json::to_string(pref).unwrap_or_default();
if pref_str.len() > MAX_PREFERENCE_SIZE {
return PrefValidation::TooLarge(pref_str.len());
}
let pref_type = match pref.get("$type").and_then(Value::as_str) {
Some(t) => t,
None => return PrefValidation::MissingType,
};
if !pref_type.starts_with(APP_BSKY_NAMESPACE) {
return PrefValidation::WrongNamespace;
}
if pref_type == PERSONAL_DETAILS_PREF && !has_full_access {
PrefValidation::Ok(Some(pref_type.to_string()))
} else {
PrefValidation::Ok(None)
}
})
.collect();
if let Some(err) = validation_results.iter().find_map(|v| match v {
PrefValidation::TooLarge(size) => Some(
ApiError::InvalidRequest(format!(
"Preference too large: {} bytes exceeds limit of {}",
size, MAX_PREFERENCE_SIZE
))
.into_response(),
),
PrefValidation::MissingType => {
Some(ApiError::InvalidRequest("Preference is missing a $type".into()).into_response())
}
PrefValidation::WrongNamespace => Some(
ApiError::InvalidRequest(format!(
"Some preferences are not in the {} namespace",
APP_BSKY_NAMESPACE
))
.into_response(),
),
PrefValidation::Ok(_) => None,
}) {
return err;
}
let forbidden_prefs: Vec<String> = validation_results
.into_iter()
.filter_map(|v| match v {
PrefValidation::Ok(Some(s)) => Some(s),
_ => None,
})
.collect();
if !forbidden_prefs.is_empty() {
return ApiError::InvalidRequest(format!(
"Do not have authorization to set preferences: {}",
forbidden_prefs.join(", ")
))
.into_response();
}
let prefs_to_save: Vec<(String, Value)> = input
.preferences
.into_iter()
.filter_map(|pref| {
let pref_type = pref.get("$type").and_then(Value::as_str)?;
if pref_type == DECLARED_AGE_PREF {
return None;
}
Some((pref_type.to_string(), pref))
})
.collect();
if state
.repos
.infra
.replace_namespace_preferences(user_id, APP_BSKY_NAMESPACE, prefs_to_save)
.await
.is_err()
{
return ApiError::InternalError(Some("Failed to save preferences".into())).into_response();
}
StatusCode::OK.into_response()
}