907 lines
29 KiB
Rust
907 lines
29 KiB
Rust
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use metrics::gauge;
|
|
use parking_lot::RwLock;
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::{info, warn};
|
|
|
|
use crate::state::AppState;
|
|
|
|
/// Cache TTL for the superuser token. PocketBase superuser JWTs are valid for
|
|
/// ~14 days by default, so 10 minutes is very conservative while eliminating
|
|
/// nearly all redundant auth requests (metrics poller, newsletter, invites, etc.).
|
|
const SUPERUSER_TOKEN_TTL_SECS: u64 = 600;
|
|
|
|
pub struct SuperuserTokenCache {
|
|
token: RwLock<Option<(String, Instant)>>,
|
|
}
|
|
|
|
impl SuperuserTokenCache {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
token: RwLock::new(None),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get a cached superuser token, or authenticate fresh if expired/missing.
|
|
pub async fn get_superuser_token(state: &AppState) -> anyhow::Result<String> {
|
|
// Check cache first (read lock — cheap, non-blocking for other readers)
|
|
{
|
|
let cached = state.superuser_token_cache.token.read();
|
|
if let Some((token, created)) = cached.as_ref() {
|
|
if created.elapsed().as_secs() < SUPERUSER_TOKEN_TTL_SECS {
|
|
return Ok(token.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cache miss or expired — fetch a fresh token
|
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
|
let token = auth_superuser(
|
|
&state.http_client,
|
|
pb_url,
|
|
&state.pocketbase_admin_email,
|
|
&state.pocketbase_admin_password,
|
|
)
|
|
.await?;
|
|
|
|
// Store in cache
|
|
{
|
|
let mut cached = state.superuser_token_cache.token.write();
|
|
*cached = Some((token.clone(), Instant::now()));
|
|
}
|
|
|
|
Ok(token)
|
|
}
|
|
|
|
#[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");
|
|
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
|
|
&& has_subscription
|
|
&& has_newsletter
|
|
&& has_ai_tokens_used
|
|
&& has_ai_tokens_week
|
|
{
|
|
info!("PocketBase users collection already has all required 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",
|
|
}));
|
|
}
|
|
|
|
if !has_ai_tokens_used {
|
|
new_fields.push(serde_json::json!({
|
|
"name": "ai_tokens_used",
|
|
"type": "number",
|
|
}));
|
|
}
|
|
|
|
if !has_ai_tokens_week {
|
|
new_fields.push(serde_json::json!({
|
|
"name": "ai_tokens_week",
|
|
"type": "number",
|
|
}));
|
|
}
|
|
|
|
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 the `saved_searches` collection has a `screenshot` file field.
|
|
/// This field was added after the initial collection schema — existing deployments
|
|
/// need it patched in so the frontend can attach screenshot JPEGs to saved searches.
|
|
async fn ensure_screenshot_field(
|
|
client: &Client,
|
|
base_url: &str,
|
|
token: &str,
|
|
) -> anyhow::Result<()> {
|
|
let url = format!("{base_url}/api/collections/saved_searches");
|
|
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 saved_searches collection ({status}): {text}");
|
|
}
|
|
|
|
let body: serde_json::Value = resp.json().await?;
|
|
let fields = body["fields"]
|
|
.as_array()
|
|
.ok_or_else(|| anyhow::anyhow!("saved_searches collection has no fields array"))?;
|
|
|
|
if fields.iter().any(|f| f["name"] == "screenshot") {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut new_fields = fields.clone();
|
|
new_fields.push(serde_json::json!({
|
|
"name": "screenshot",
|
|
"type": "file",
|
|
"required": false,
|
|
"maxSelect": 1,
|
|
"maxSize": 10485760,
|
|
"mimeTypes": ["image/png", "image/jpeg", "image/webp"],
|
|
}));
|
|
|
|
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 screenshot field to saved_searches ({status}): {text}");
|
|
}
|
|
|
|
info!("Added screenshot file field to PocketBase collection 'saved_searches'");
|
|
Ok(())
|
|
}
|
|
|
|
/// Ensure a collection has a `notes` text field for user annotations.
|
|
async fn ensure_notes_field(
|
|
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"))?;
|
|
|
|
if fields.iter().any(|f| f["name"] == "notes") {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut new_fields = fields.clone();
|
|
new_fields.push(serde_json::json!({
|
|
"name": "notes",
|
|
"type": "text",
|
|
"required": false,
|
|
}));
|
|
|
|
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 notes field to {collection_name} ({status}): {text}");
|
|
}
|
|
|
|
info!("Added notes text field to PocketBase collection '{collection_name}'");
|
|
Ok(())
|
|
}
|
|
|
|
/// 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::text("notes", 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_saved_searches_rules(client, base_url, &token).await?;
|
|
ensure_autodate_fields(client, base_url, &token, "saved_searches").await?;
|
|
ensure_screenshot_field(client, base_url, &token).await?;
|
|
ensure_notes_field(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::text("notes", 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?;
|
|
ensure_notes_field(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(())
|
|
}
|
|
|
|
/// Spawn a background task that polls PocketBase every 60 seconds for collection counts
|
|
/// and exposes them as Prometheus gauges.
|
|
pub fn start_metrics_poller(shared: Arc<crate::state::SharedState>) {
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
|
loop {
|
|
interval.tick().await;
|
|
let state = shared.load_state();
|
|
poll_pocketbase_counts(&state).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn poll_pocketbase_counts(state: &AppState) {
|
|
let token = match get_superuser_token(state).await {
|
|
Ok(tk) => tk,
|
|
Err(err) => {
|
|
warn!("PocketBase metrics poll auth failed: {err}");
|
|
return;
|
|
}
|
|
};
|
|
let pb_url = state.pocketbase_url.trim_end_matches('/');
|
|
|
|
// Simple collection counts
|
|
for (collection, metric_name) in [
|
|
("users", "pocketbase_users_total"),
|
|
("saved_searches", "pocketbase_saved_searches_total"),
|
|
("saved_properties", "pocketbase_saved_properties_total"),
|
|
] {
|
|
if let Some(total) = pb_count(&state.http_client, pb_url, &token, collection, None).await {
|
|
gauge!(metric_name).set(total as f64);
|
|
}
|
|
}
|
|
|
|
// Invite metrics: by type and redeemed status
|
|
for (filter, metric, labels) in [
|
|
(None, "invites_total", ("type", "all")),
|
|
(
|
|
Some(r#"invite_type="admin""#),
|
|
"invites_total",
|
|
("type", "admin"),
|
|
),
|
|
(
|
|
Some(r#"invite_type="referral""#),
|
|
"invites_total",
|
|
("type", "referral"),
|
|
),
|
|
(
|
|
Some(r#"used_by_id!="""#),
|
|
"invites_total",
|
|
("type", "redeemed"),
|
|
),
|
|
] {
|
|
if let Some(total) = pb_count(&state.http_client, pb_url, &token, "invites", filter).await {
|
|
gauge!(metric, labels.0 => labels.1.to_string()).set(total as f64);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn pb_count(
|
|
client: &reqwest::Client,
|
|
pb_url: &str,
|
|
token: &str,
|
|
collection: &str,
|
|
filter: Option<&str>,
|
|
) -> Option<u64> {
|
|
let mut url = format!("{pb_url}/api/collections/{collection}/records?perPage=1");
|
|
if let Some(f) = filter {
|
|
url.push_str(&format!("&filter={}", urlencoding::encode(f)));
|
|
}
|
|
match client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(resp) if resp.status().is_success() => {
|
|
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
|
return body.get("totalItems").and_then(|v| v.as_u64());
|
|
}
|
|
None
|
|
}
|
|
Ok(resp) => {
|
|
warn!(
|
|
"PocketBase {collection} count query failed: {}",
|
|
resp.status()
|
|
);
|
|
None
|
|
}
|
|
Err(err) => {
|
|
warn!("PocketBase {collection} count query error: {err}");
|
|
None
|
|
}
|
|
}
|
|
}
|