perfect-postcode/server-rs/src/pocketbase.rs
2026-03-14 21:36:00 +00:00

624 lines
20 KiB
Rust

use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::info;
#[derive(Deserialize)]
struct AuthResponse {
token: String,
}
#[derive(Deserialize)]
struct CollectionList {
items: Vec<CollectionItem>,
}
#[derive(Deserialize)]
struct CollectionItem {
name: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateCollection {
name: String,
r#type: String,
fields: Vec<Field>,
#[serde(skip_serializing_if = "Option::is_none")]
list_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
view_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
create_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
update_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
delete_rule: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Field {
name: String,
r#type: String,
#[serde(skip_serializing_if = "Option::is_none")]
required: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
max_select: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
collection_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
max_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
mime_types: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
on_create: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
on_update: Option<bool>,
}
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<String> {
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<Vec<String>> {
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<String> {
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(())
}