use reqwest::Client; use serde::{Deserialize, Serialize}; use tracing::info; #[derive(Deserialize)] struct AuthResponse { token: String, } #[derive(Deserialize)] struct CollectionList { items: Vec, } #[derive(Deserialize)] struct CollectionItem { name: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct CreateCollection { name: String, r#type: String, fields: Vec, #[serde(skip_serializing_if = "Option::is_none")] list_rule: Option, #[serde(skip_serializing_if = "Option::is_none")] view_rule: Option, #[serde(skip_serializing_if = "Option::is_none")] create_rule: Option, #[serde(skip_serializing_if = "Option::is_none")] update_rule: Option, #[serde(skip_serializing_if = "Option::is_none")] delete_rule: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct Field { name: String, r#type: String, #[serde(skip_serializing_if = "Option::is_none")] required: Option, #[serde(skip_serializing_if = "Option::is_none")] max_select: Option, #[serde(skip_serializing_if = "Option::is_none")] collection_id: Option, #[serde(skip_serializing_if = "Option::is_none")] max_size: Option, #[serde(skip_serializing_if = "Option::is_none")] mime_types: Option>, #[serde(skip_serializing_if = "Option::is_none")] on_create: Option, #[serde(skip_serializing_if = "Option::is_none")] on_update: Option, } impl Field { fn text(name: &str, required: bool) -> Self { Self { name: name.to_string(), r#type: "text".to_string(), required: Some(required), max_select: None, collection_id: None, max_size: None, mime_types: None, on_create: None, on_update: None, } } fn file(name: &str, mime_types: Vec<&str>) -> Self { Self { name: name.to_string(), r#type: "file".to_string(), required: Some(false), max_select: Some(1), collection_id: None, max_size: Some(10 * 1024 * 1024), // 10 MB mime_types: Some(mime_types.into_iter().map(String::from).collect()), on_create: None, on_update: None, } } fn relation(name: &str, collection_id: &str) -> Self { Self { name: name.to_string(), r#type: "relation".to_string(), required: Some(true), max_select: Some(1), collection_id: Some(collection_id.to_string()), max_size: None, mime_types: None, on_create: None, on_update: None, } } fn autodate(name: &str, on_create: bool, on_update: bool) -> Self { Self { name: name.to_string(), r#type: "autodate".to_string(), required: None, max_select: None, collection_id: None, max_size: None, mime_types: None, on_create: Some(on_create), on_update: Some(on_update), } } } pub async fn auth_superuser( client: &Client, base_url: &str, email: &str, password: &str, ) -> anyhow::Result { let url = format!("{base_url}/api/collections/_superusers/auth-with-password"); let resp = client .post(&url) .json(&serde_json::json!({ "identity": email, "password": password, })) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("PocketBase superuser auth failed ({status}): {text}"); } let body: AuthResponse = resp.json().await?; Ok(body.token) } async fn list_collections( client: &Client, base_url: &str, token: &str, ) -> anyhow::Result> { let url = format!("{base_url}/api/collections?perPage=200"); 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 list PocketBase collections ({status}): {text}"); } let body: CollectionList = resp.json().await?; Ok(body.items.into_iter().map(|c| c.name).collect()) } async fn create_collection( client: &Client, base_url: &str, token: &str, collection: CreateCollection, ) -> anyhow::Result<()> { let name = collection.name.clone(); let resp = client .post(format!("{base_url}/api/collections")) .header("Authorization", format!("Bearer {token}")) .json(&collection) .send() .await?; if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); anyhow::bail!("Failed to create collection '{name}' ({status}): {text}"); } info!("Created PocketBase collection: {name}"); Ok(()) } /// Look up the internal ID of the `users` auth collection. async fn find_users_collection_id( client: &Client, base_url: &str, token: &str, ) -> anyhow::Result { let url = format!("{base_url}/api/collections/users"); 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 users collection ({status}): {text}"); } let body: serde_json::Value = resp.json().await?; let id = body["id"] .as_str() .ok_or_else(|| anyhow::anyhow!("users collection has no id field"))?; Ok(id.to_string()) } /// Ensure `is_admin` (bool) and `subscription` (text) fields exist on the `users` collection. /// PocketBase PATCH replaces the entire `fields` array, so we must preserve existing fields. async fn ensure_user_fields( client: &Client, base_url: &str, token: &str, ) -> anyhow::Result<()> { let url = format!("{base_url}/api/collections/users"); 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 users collection ({status}): {text}"); } let body: serde_json::Value = resp.json().await?; let fields = body["fields"] .as_array() .ok_or_else(|| anyhow::anyhow!("users collection has no fields array"))?; let has_is_admin = fields.iter().any(|f| f["name"] == "is_admin"); let has_subscription = fields.iter().any(|f| f["name"] == "subscription"); let has_newsletter = fields.iter().any(|f| f["name"] == "newsletter"); if has_is_admin && has_subscription && has_newsletter { info!("PocketBase users collection already has is_admin, subscription, and newsletter fields"); return Ok(()); } let mut new_fields = fields.clone(); if !has_is_admin { new_fields.push(serde_json::json!({ "name": "is_admin", "type": "bool", })); } if !has_subscription { new_fields.push(serde_json::json!({ "name": "subscription", "type": "text", })); } if !has_newsletter { new_fields.push(serde_json::json!({ "name": "newsletter", "type": "bool", })); } 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 users collection ({status}): {text}"); } info!("Added missing fields to PocketBase users collection"); Ok(()) } /// Ensure a collection has API rules allowing users to manage their own records. async fn ensure_user_owned_rules( client: &Client, base_url: &str, token: &str, collection_name: &str, ) -> anyhow::Result<()> { let url = format!("{base_url}/api/collections/{collection_name}"); let user_only = "user = @request.auth.id"; let resp = client .patch(&url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "listRule": user_only, "viewRule": user_only, "createRule": user_only, "updateRule": user_only, "deleteRule": user_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 {collection_name} API rules ({status}): {text}"); } info!("PocketBase collection '{collection_name}' API rules 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, base_url: &str, token: &str, ) -> anyhow::Result<()> { ensure_user_owned_rules(client, base_url, token, "saved_searches").await } /// Ensure a collection has `created` and `updated` autodate fields. /// PocketBase 0.23+ no longer adds these automatically — they must be explicit. async fn ensure_autodate_fields( client: &Client, base_url: &str, token: &str, collection_name: &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 fields = body["fields"] .as_array() .ok_or_else(|| anyhow::anyhow!("{collection_name} collection has no fields array"))?; let has_created = fields.iter().any(|f| f["name"] == "created"); let has_updated = fields.iter().any(|f| f["name"] == "updated"); if has_created && has_updated { return Ok(()); } let mut new_fields = fields.clone(); if !has_created { new_fields.push(serde_json::json!({ "name": "created", "type": "autodate", "onCreate": true, "onUpdate": false, })); } if !has_updated { new_fields.push(serde_json::json!({ "name": "updated", "type": "autodate", "onCreate": true, "onUpdate": true, })); } 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 add autodate fields to {collection_name} ({status}): {text}"); } info!("Added created/updated autodate fields to PocketBase collection '{collection_name}'"); Ok(()) } /// Ensure the `saved_searches` and `short_urls` collections exist in PocketBase, /// and that the `users` collection has `is_admin` and `subscription` fields. /// Authenticates as superuser, checks existing collections, and creates any that are missing. pub async fn ensure_collections( client: &Client, base_url: &str, admin_email: &str, admin_password: &str, ) -> anyhow::Result<()> { let base_url = base_url.trim_end_matches('/'); let token = auth_superuser(client, base_url, admin_email, admin_password).await?; let existing = list_collections(client, base_url, &token).await?; ensure_user_fields(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?; let user_only = Some("user = @request.auth.id".to_string()); create_collection( client, base_url, &token, CreateCollection { name: "saved_searches".to_string(), r#type: "base".to_string(), fields: vec![ Field::relation("user", &users_id), Field::text("name", true), Field::text("params", true), Field::file("screenshot", vec!["image/png", "image/jpeg", "image/webp"]), Field::autodate("created", true, false), Field::autodate("updated", true, true), ], list_rule: user_only.clone(), view_rule: user_only.clone(), create_rule: user_only.clone(), update_rule: user_only.clone(), delete_rule: user_only, }, ) .await?; } else { ensure_saved_searches_rules(client, base_url, &token).await?; ensure_autodate_fields(client, base_url, &token, "saved_searches").await?; } if !existing.iter().any(|n| n == "saved_properties") { let users_id = find_users_collection_id(client, base_url, &token).await?; let user_only = Some("user = @request.auth.id".to_string()); create_collection( client, base_url, &token, CreateCollection { name: "saved_properties".to_string(), r#type: "base".to_string(), fields: vec![ Field::relation("user", &users_id), Field::text("address", true), Field::text("postcode", true), Field::text("data", false), Field::autodate("created", true, false), Field::autodate("updated", true, true), ], list_rule: user_only.clone(), view_rule: user_only.clone(), create_rule: user_only.clone(), update_rule: user_only.clone(), delete_rule: user_only, }, ) .await?; } else { ensure_user_owned_rules(client, base_url, &token, "saved_properties").await?; ensure_autodate_fields(client, base_url, &token, "saved_properties").await?; } if !existing.iter().any(|n| n == "invites") { create_collection( client, base_url, &token, CreateCollection { name: "invites".to_string(), r#type: "base".to_string(), fields: vec![ Field::text("code", true), Field::text("created_by", true), Field::text("invite_type", true), Field::text("used_by_id", false), Field::text("used_at", 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, }, ) .await?; } else { ensure_autodate_fields(client, base_url, &token, "invites").await?; } if !existing.iter().any(|n| n == "short_urls") { create_collection( client, base_url, &token, CreateCollection { name: "short_urls".to_string(), r#type: "base".to_string(), fields: vec![ Field::text("code", true), Field::text("params", true), 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, }, ) .await?; } else { ensure_autodate_fields(client, base_url, &token, "short_urls").await?; } Ok(()) } /// Configure Google OAuth2 provider in PocketBase settings. /// Also sets `meta.appUrl` so OAuth callbacks route to `{public_url}/pb`. pub async fn ensure_oauth_providers( client: &Client, base_url: &str, admin_email: &str, admin_password: &str, public_url: &str, google_client_id: &str, google_client_secret: &str, ) -> anyhow::Result<()> { let base_url = base_url.trim_end_matches('/'); let token = auth_superuser(client, base_url, admin_email, admin_password).await?; // Set meta.appURL in global settings for OAuth redirects let app_url = format!("{}/pb", public_url.trim_end_matches('/')); let settings_url = format!("{base_url}/api/settings"); let patch_resp = client .patch(&settings_url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "meta": { "appURL": app_url } })) .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 update PocketBase meta.appURL ({status}): {text}"); } info!("PocketBase meta.appURL set to {app_url}"); // PocketBase 0.23+: OAuth providers are configured per-collection, not in global settings. // GET the users collection to update its oauth2 config. let collection_url = format!("{base_url}/api/collections/users"); let resp = client .get(&collection_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 users collection ({status}): {text}"); } let mut collection: serde_json::Value = resp.json().await?; let oauth2 = collection .get_mut("oauth2") .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2 field"))?; // Ensure enabled oauth2["enabled"] = serde_json::json!(true); let providers = oauth2 .get_mut("providers") .and_then(|v| v.as_array_mut()) .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2.providers array"))?; let google = match providers .iter() .position(|p| p.get("name").and_then(|n| n.as_str()) == Some("google")) { Some(idx) => &mut providers[idx], None => { info!("Google provider not found — adding it"); providers.push(serde_json::json!({"name": "google"})); providers.last_mut().expect("just pushed") } }; google["clientId"] = serde_json::json!(google_client_id); google["clientSecret"] = serde_json::json!(google_client_secret); // PATCH the collection let patch_resp = client .patch(&collection_url) .header("Authorization", format!("Bearer {token}")) .json(&serde_json::json!({ "oauth2": oauth2 })) .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 update users collection OAuth ({status}): {text}"); } info!("PocketBase OAuth configured on users collection"); Ok(()) }