Rust things

This commit is contained in:
Andras Schmelczer 2026-05-10 14:55:43 +01:00
parent fc10381692
commit 3debacab4f
30 changed files with 3257 additions and 647 deletions

View file

@ -88,6 +88,8 @@ struct CreateCollection {
update_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
delete_rule: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
indexes: Vec<String>,
}
#[derive(Serialize)]
@ -308,12 +310,13 @@ async fn ensure_user_fields(client: &Client, base_url: &str, token: &str) -> any
let has_ai_tokens_used = fields.iter().any(|f| f["name"] == "ai_tokens_used");
let has_ai_tokens_week = fields.iter().any(|f| f["name"] == "ai_tokens_week");
if has_is_admin
let has_all_required_fields = has_is_admin
&& has_subscription
&& has_newsletter
&& has_ai_tokens_used
&& has_ai_tokens_week
{
&& has_ai_tokens_week;
if has_all_required_fields {
info!("PocketBase users collection already has all required fields");
return Ok(());
}
@ -372,6 +375,52 @@ async fn ensure_user_fields(client: &Client, base_url: &str, token: &str) -> any
Ok(())
}
/// Ensure clients can manage normal account data but cannot self-grant paid or
/// admin-only state. Superuser writes from the Rust API bypass these rules.
async fn ensure_user_auth_rules(
client: &Client,
base_url: &str,
token: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/users");
let self_only = "id = @request.auth.id";
let protected_fields_absent = concat!(
"@request.body.subscription:isset = false",
" && @request.body.is_admin:isset = false",
" && @request.body.ai_tokens_used:isset = false",
" && @request.body.ai_tokens_week:isset = false"
);
let protected_fields_unchanged = concat!(
"@request.body.subscription:changed = false",
" && @request.body.is_admin:changed = false",
" && @request.body.ai_tokens_used:changed = false",
" && @request.body.ai_tokens_week:changed = false"
);
let update_rule = format!("{self_only} && {protected_fields_unchanged}");
let resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"listRule": self_only,
"viewRule": self_only,
"createRule": protected_fields_absent,
"updateRule": update_rule,
"deleteRule": self_only,
}))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to update users collection API rules ({status}): {text}");
}
info!("PocketBase users collection API rules hardened");
Ok(())
}
/// Ensure a collection has API rules allowing users to manage their own records.
async fn ensure_user_owned_rules(
client: &Client,
@ -404,6 +453,263 @@ async fn ensure_user_owned_rules(
Ok(())
}
/// Ensure a collection is accessible only via server-side superuser calls.
async fn ensure_server_only_rules(
client: &Client,
base_url: &str,
token: &str,
collection_name: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/{collection_name}");
let resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({
"listRule": serde_json::Value::Null,
"viewRule": serde_json::Value::Null,
"createRule": serde_json::Value::Null,
"updateRule": serde_json::Value::Null,
"deleteRule": serde_json::Value::Null,
}))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to lock {collection_name} API rules ({status}): {text}");
}
info!("PocketBase collection '{collection_name}' locked to superuser access");
Ok(())
}
async fn ensure_checkout_sessions_fields(
client: &Client,
base_url: &str,
token: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/checkout_sessions");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to fetch checkout_sessions collection ({status}): {text}");
}
let body: serde_json::Value = resp.json().await?;
let fields = body["fields"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("checkout_sessions collection has no fields array"))?;
let users_id = find_users_collection_id(client, base_url, token).await?;
let mut new_fields = fields.clone();
let mut add_field = |name: &str, field: serde_json::Value| {
if !fields.iter().any(|f| f["name"] == name) {
new_fields.push(field);
}
};
add_field(
"user",
serde_json::json!({
"name": "user",
"type": "relation",
"required": true,
"maxSelect": 1,
"collectionId": users_id,
}),
);
add_field(
"stripe_session_id",
serde_json::json!({ "name": "stripe_session_id", "type": "text", "required": false }),
);
add_field(
"checkout_url",
serde_json::json!({ "name": "checkout_url", "type": "text", "required": false }),
);
add_field(
"amount_pence",
serde_json::json!({ "name": "amount_pence", "type": "number" }),
);
add_field(
"expected_total_pence",
serde_json::json!({ "name": "expected_total_pence", "type": "number" }),
);
add_field(
"currency",
serde_json::json!({ "name": "currency", "type": "text", "required": true }),
);
add_field(
"discount_coupon_id",
serde_json::json!({ "name": "discount_coupon_id", "type": "text", "required": false }),
);
add_field(
"referral_invite_id",
serde_json::json!({ "name": "referral_invite_id", "type": "text", "required": false }),
);
add_field(
"status",
serde_json::json!({ "name": "status", "type": "text", "required": true }),
);
add_field(
"expires_at_unix",
serde_json::json!({ "name": "expires_at_unix", "type": "number" }),
);
add_field(
"paid_amount_pence",
serde_json::json!({ "name": "paid_amount_pence", "type": "number" }),
);
add_field(
"completed_at_unix",
serde_json::json!({ "name": "completed_at_unix", "type": "text", "required": false }),
);
if new_fields.len() == fields.len() {
return Ok(());
}
let patch_resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "fields": new_fields }))
.send()
.await?;
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to patch checkout_sessions fields ({status}): {text}");
}
info!("PocketBase checkout_sessions collection fields updated");
Ok(())
}
async fn ensure_checkout_locks_fields(
client: &Client,
base_url: &str,
token: &str,
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/checkout_locks");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to fetch checkout_locks collection ({status}): {text}");
}
let body: serde_json::Value = resp.json().await?;
let fields = body["fields"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("checkout_locks collection has no fields array"))?;
let mut new_fields = fields.clone();
let mut add_field = |name: &str, field: serde_json::Value| {
if !fields.iter().any(|f| f["name"] == name) {
new_fields.push(field);
}
};
add_field(
"name",
serde_json::json!({ "name": "name", "type": "text", "required": true }),
);
add_field(
"owner",
serde_json::json!({ "name": "owner", "type": "text", "required": true }),
);
add_field(
"expires_at_unix",
serde_json::json!({ "name": "expires_at_unix", "type": "number" }),
);
if new_fields.len() == fields.len() {
return Ok(());
}
let patch_resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "fields": new_fields }))
.send()
.await?;
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to patch checkout_locks fields ({status}): {text}");
}
info!("PocketBase checkout_locks collection fields updated");
Ok(())
}
async fn ensure_collection_indexes(
client: &Client,
base_url: &str,
token: &str,
collection_name: &str,
required_indexes: &[(&str, &str)],
) -> anyhow::Result<()> {
let url = format!("{base_url}/api/collections/{collection_name}");
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to fetch {collection_name} collection ({status}): {text}");
}
let body: serde_json::Value = resp.json().await?;
let indexes = body["indexes"].as_array().cloned().unwrap_or_default();
let mut new_indexes = indexes.clone();
for (index_name, create_sql) in required_indexes {
let exists = indexes
.iter()
.filter_map(|idx| idx.as_str())
.any(|idx| idx.contains(index_name));
if !exists {
new_indexes.push(serde_json::Value::String((*create_sql).to_string()));
}
}
if new_indexes.len() == indexes.len() {
return Ok(());
}
let patch_resp = client
.patch(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "indexes": new_indexes }))
.send()
.await?;
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to patch {collection_name} indexes ({status}): {text}");
}
info!("PocketBase collection '{collection_name}' indexes updated");
Ok(())
}
/// Ensure the `saved_searches` collection has API rules allowing users to manage their own records.
async fn ensure_saved_searches_rules(
client: &Client,
@ -608,6 +914,7 @@ pub async fn ensure_collections(
let existing = list_collections(client, base_url, &token).await?;
ensure_user_fields(client, base_url, &token).await?;
ensure_user_auth_rules(client, base_url, &token).await?;
if !existing.iter().any(|n| n == "saved_searches") {
let users_id = find_users_collection_id(client, base_url, &token).await?;
@ -633,6 +940,7 @@ pub async fn ensure_collections(
create_rule: user_only.clone(),
update_rule: user_only.clone(),
delete_rule: user_only,
indexes: Vec::new(),
},
)
.await?;
@ -667,6 +975,7 @@ pub async fn ensure_collections(
create_rule: user_only.clone(),
update_rule: user_only.clone(),
delete_rule: user_only,
indexes: Vec::new(),
},
)
.await?;
@ -698,6 +1007,7 @@ pub async fn ensure_collections(
create_rule: None,
update_rule: None,
delete_rule: None,
indexes: Vec::new(),
},
)
.await?;
@ -705,6 +1015,86 @@ pub async fn ensure_collections(
ensure_autodate_fields(client, base_url, &token, "invites").await?;
}
if !existing.iter().any(|n| n == "checkout_sessions") {
let users_id = find_users_collection_id(client, base_url, &token).await?;
create_collection(
client,
base_url,
&token,
CreateCollection {
name: "checkout_sessions".to_string(),
r#type: "base".to_string(),
fields: vec![
Field::relation("user", &users_id),
Field::text("stripe_session_id", false),
Field::text("checkout_url", false),
Field::number("amount_pence"),
Field::number("expected_total_pence"),
Field::text("currency", true),
Field::text("discount_coupon_id", false),
Field::text("referral_invite_id", false),
Field::text("status", true),
Field::number("expires_at_unix"),
Field::number("paid_amount_pence"),
Field::text("completed_at_unix", false),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
list_rule: None,
view_rule: None,
create_rule: None,
update_rule: None,
delete_rule: None,
indexes: Vec::new(),
},
)
.await?;
} else {
ensure_server_only_rules(client, base_url, &token, "checkout_sessions").await?;
ensure_checkout_sessions_fields(client, base_url, &token).await?;
ensure_autodate_fields(client, base_url, &token, "checkout_sessions").await?;
}
let checkout_locks_name_index =
"CREATE UNIQUE INDEX idx_checkout_locks_name ON checkout_locks (name)";
if !existing.iter().any(|n| n == "checkout_locks") {
create_collection(
client,
base_url,
&token,
CreateCollection {
name: "checkout_locks".to_string(),
r#type: "base".to_string(),
fields: vec![
Field::text("name", true),
Field::text("owner", true),
Field::number("expires_at_unix"),
Field::autodate("created", true, false),
Field::autodate("updated", true, true),
],
list_rule: None,
view_rule: None,
create_rule: None,
update_rule: None,
delete_rule: None,
indexes: vec![checkout_locks_name_index.to_string()],
},
)
.await?;
} else {
ensure_server_only_rules(client, base_url, &token, "checkout_locks").await?;
ensure_checkout_locks_fields(client, base_url, &token).await?;
ensure_autodate_fields(client, base_url, &token, "checkout_locks").await?;
ensure_collection_indexes(
client,
base_url,
&token,
"checkout_locks",
&[("idx_checkout_locks_name", checkout_locks_name_index)],
)
.await?;
}
if !existing.iter().any(|n| n == "short_urls") {
create_collection(
client,
@ -724,6 +1114,7 @@ pub async fn ensure_collections(
create_rule: None,
update_rule: None,
delete_rule: None,
indexes: Vec::new(),
},
)
.await?;
@ -753,6 +1144,7 @@ pub async fn ensure_collections(
create_rule: None,
update_rule: None,
delete_rule: None,
indexes: Vec::new(),
},
)
.await?;
@ -785,6 +1177,7 @@ pub async fn ensure_collections(
create_rule: None,
update_rule: None,
delete_rule: None,
indexes: Vec::new(),
},
)
.await?;