Rust things
This commit is contained in:
parent
fc10381692
commit
3debacab4f
30 changed files with 3257 additions and 647 deletions
|
|
@ -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?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue