defter 0.7.6

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 })
}

#[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(_))
        ));
    }
}