This commit is contained in:
Andras Schmelczer 2026-02-14 12:53:29 +00:00
parent 3a3f899ea2
commit 128b3191e7
68 changed files with 28060 additions and 1152 deletions

235
server-rs/src/pocketbase.rs Normal file
View file

@ -0,0 +1,235 @@
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)]
struct CreateCollection {
name: String,
r#type: String,
fields: Vec<Field>,
}
#[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>>,
}
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,
}
}
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()),
}
}
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,
}
}
}
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 the `saved_searches` and `short_urls` collections exist in PocketBase.
/// 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?;
if !existing.iter().any(|n| n == "saved_searches") {
let users_id = find_users_collection_id(client, base_url, &token).await?;
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"]),
],
},
)
.await?;
} else {
info!("PocketBase collection 'saved_searches' already exists");
}
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),
],
},
)
.await?;
} else {
info!("PocketBase collection 'short_urls' already exists");
}
Ok(())
}