defter 0.8.5

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! The one question defter asks zotero.org: whose is this API key. A new user's
//! ID is their zotero.org user ID, so that their Zotero can sync the same
//! library with zotero.org and with defter. The key is used for this one
//! request: it is never stored or logged.

use std::time::Duration;

use serde_json::Value;

/// zotero.org's Web API, when `--zotero-api-url` is not set.
pub const DEFAULT_API_URL: &str = "https://api.zotero.org/";

/// How long zotero.org may take: a person waits for it.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);

/// zotero.org's keys are 24 letters and digits; anything far off is not sent.
const MAX_KEY_LENGTH: usize = 64;

/// A zotero.org account, as its key's `/keys/current` names it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Account {
    pub user_id: u64,
    pub username: String,
}

/// Why no account came of a key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
    /// zotero.org does not know the key, or it is not one.
    Rejected,
    /// zotero.org could not be asked, or answered nonsense.
    Unreachable(String),
}

/// The account `key` belongs to, asked of the Web API at `api_url`.
pub async fn account(http: &reqwest::Client, api_url: &str, key: &str) -> Result<Account, Refusal> {
    if key.is_empty()
        || key.len() > MAX_KEY_LENGTH
        || !key.bytes().all(|b| b.is_ascii_alphanumeric())
    {
        return Err(Refusal::Rejected);
    }
    let url = format!("{}/keys/current", api_url.trim_end_matches('/'));
    let response = http
        .get(&url)
        .header("Zotero-API-Version", "3")
        .header("Zotero-API-Key", key)
        .timeout(REQUEST_TIMEOUT)
        .send()
        .await
        // Without the URL: it holds no key, but says nothing useful either.
        .map_err(|e| Refusal::Unreachable(e.without_url().to_string()))?;
    let status = response.status();
    if matches!(status.as_u16(), 400 | 401 | 403 | 404) {
        return Err(Refusal::Rejected);
    }
    if !status.is_success() {
        return Err(Refusal::Unreachable(format!("zotero.org: {status}")));
    }
    let body: Value = response
        .json()
        .await
        .map_err(|e| Refusal::Unreachable(format!("zotero.org: not JSON: {}", e.without_url())))?;
    let user_id = body
        .get("userID")
        .and_then(Value::as_u64)
        .filter(|id| *id > 0)
        .ok_or_else(|| Refusal::Unreachable("zotero.org: no userID".to_string()))?;
    let username = body
        .get("username")
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or("")
        .to_string();
    Ok(Account { user_id, username })
}

/// A group on zotero.org, as `/users/<id>/groups` describes it to a member.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Group {
    pub id: u64,
    pub name: String,
    pub description: String,
    /// `members` or `admins`.
    pub library_editing: String,
    /// `members`, `admins` or `none`.
    pub file_editing: String,
    pub owner: u64,
    pub admins: Vec<u64>,
    /// `None` when zotero.org does not list them.
    pub members: Option<Vec<u64>>,
    /// `Private`, `PublicClosed` or `PublicOpen`.
    pub kind: String,
    /// `members` or `all`.
    pub library_reading: String,
    pub url: String,
}

/// Groups listed a request at most, as zotero.org pages them.
const GROUPS_PAGE: usize = 100;

/// The groups of user `user_id`, whose key `key` is, asked of the Web API at
/// `api_url` a page at a time. A setting zotero.org may add later counts as the
/// most cautious one.
pub async fn groups(
    http: &reqwest::Client,
    api_url: &str,
    key: &str,
    user_id: u64,
) -> Result<Vec<Group>, Refusal> {
    let mut out = Vec::new();
    loop {
        let url = format!(
            "{}/users/{user_id}/groups?limit={GROUPS_PAGE}&start={}",
            api_url.trim_end_matches('/'),
            out.len()
        );
        let response = http
            .get(&url)
            .header("Zotero-API-Version", "3")
            .header("Zotero-API-Key", key)
            .timeout(REQUEST_TIMEOUT)
            .send()
            .await
            .map_err(|e| Refusal::Unreachable(e.without_url().to_string()))?;
        let status = response.status();
        if matches!(status.as_u16(), 400 | 401 | 403 | 404) {
            return Err(Refusal::Rejected);
        }
        if !status.is_success() {
            return Err(Refusal::Unreachable(format!("zotero.org: {status}")));
        }
        let total = response
            .headers()
            .get("total-results")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.parse::<usize>().ok());
        let page: Vec<Value> = response.json().await.map_err(|e| {
            Refusal::Unreachable(format!("zotero.org: not JSON: {}", e.without_url()))
        })?;
        let count = page.len();
        out.extend(page.iter().filter_map(group));
        if count < GROUPS_PAGE || total.is_some_and(|total| out.len() >= total) {
            return Ok(out);
        }
    }
}

/// A group of a `/groups` listing, from its `data`.
fn group(entry: &Value) -> Option<Group> {
    let data = entry.get("data")?;
    let text = |k: &str| {
        data.get(k)
            .and_then(Value::as_str)
            .unwrap_or("")
            .to_string()
    };
    let ids = |k: &str| {
        data.get(k)
            .and_then(Value::as_array)
            .map(|a| a.iter().filter_map(Value::as_u64).collect::<Vec<_>>())
    };
    let id = data
        .get("id")
        .and_then(Value::as_u64)
        .filter(|id| *id > 0)?;
    Some(Group {
        id,
        name: text("name"),
        description: text("description"),
        library_editing: match text("libraryEditing").as_str() {
            "members" => "members",
            _ => "admins",
        }
        .to_string(),
        file_editing: match text("fileEditing").as_str() {
            "members" => "members",
            "admins" => "admins",
            _ => "none",
        }
        .to_string(),
        owner: data.get("owner").and_then(Value::as_u64).unwrap_or(0),
        admins: ids("admins").unwrap_or_default(),
        members: ids("members"),
        kind: match text("type").as_str() {
            "PublicOpen" => "PublicOpen",
            "PublicClosed" => "PublicClosed",
            _ => "Private",
        }
        .to_string(),
        library_reading: match text("libraryReading").as_str() {
            "all" => "all",
            _ => "members",
        }
        .to_string(),
        url: Some(text("url"))
            .filter(|u| u.starts_with("https://") || u.starts_with("http://"))
            .unwrap_or_default(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{Json, Router, http::HeaderMap, http::StatusCode, routing::get};
    use serde_json::json;

    /// A zotero.org that knows the key `good` as user 4711 and answers `broken`
    /// with an error.
    async fn mock() -> String {
        let app = Router::new().route(
            "/keys/current",
            get(|headers: HeaderMap| async move {
                match headers.get("zotero-api-key").and_then(|v| v.to_str().ok()) {
                    Some("good") => (
                        StatusCode::OK,
                        Json(json!({"key": "good", "userID": 4711, "username": "ada"})),
                    ),
                    Some("noid") => (StatusCode::OK, Json(json!({"key": "noid"}))),
                    Some("broken") => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({}))),
                    _ => (StatusCode::FORBIDDEN, Json(json!("Invalid key"))),
                }
            }),
        );
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let url = format!("http://{}/", listener.local_addr().unwrap());
        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
        url
    }

    #[tokio::test]
    async fn asks_whose_key_it_is() {
        let url = mock().await;
        let http = reqwest::Client::new();
        assert_eq!(
            account(&http, &url, "good").await,
            Ok(Account {
                user_id: 4711,
                username: "ada".into()
            })
        );
        for rejected in ["wrong", "", "has space", "line\nbreak", &"a".repeat(65)] {
            assert_eq!(
                account(&http, &url, rejected).await,
                Err(Refusal::Rejected),
                "{rejected:?}"
            );
        }
        for unreachable in ["broken", "noid"] {
            assert!(matches!(
                account(&http, &url, unreachable).await,
                Err(Refusal::Unreachable(_))
            ));
        }
        assert!(matches!(
            account(&http, "http://127.0.0.1:9/", "good").await,
            Err(Refusal::Unreachable(_))
        ));
    }
}