defter 0.8.5

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! Application state and the HTTP router.

use std::sync::Arc;
use std::time::{Duration, Instant};

use axum::{
    Router,
    body::{Body, Bytes},
    extract::{DefaultBodyLimit, Request},
    http::StatusCode,
    middleware::{self, Next},
    response::{IntoResponse, Response},
};
use tower_http::compression::CompressionLayer;

use crate::auth::{self, Keys, LoginSession, Tokens};
use crate::control::{Control, LibraryRef, User};
use crate::registry::{Libraries, Library};
use crate::storage::Storage;
use crate::{ui, zotero};

/// Largest JSON request body, after gzip decoding. Zotero batches at most 50
/// objects (and 500 KB of full text) a request.
pub const MAX_JSON_BODY: usize = 64 * 1024 * 1024;

/// How long an authorized upload may take to be sent and registered.
const UPLOAD_TTL: Duration = Duration::from_secs(3600);

/// How long a proxied download link stays valid.
const DOWNLOAD_TTL: Duration = Duration::from_secs(300);

#[derive(Clone)]
pub struct Config {
    /// The owner's user ID, their zotero.org one: every API key is theirs.
    pub user_id: u64,
    /// The address clients reach the server at (the reverse proxy's), without
    /// a trailing slash. Login, upload and download URLs are built on it.
    pub public_url: String,
    /// Require the SSO proxy to forward this identity on `POST /login`.
    pub login_authorized_user: Option<String>,
    /// Largest attachment file accepted, in bytes.
    pub max_upload: u64,
    /// Where `GET /schema` gets Zotero's item schema.
    pub schema_url: String,
    /// zotero.org's Web API, asked whose key a person signing up gave.
    pub zotero_api_url: String,
    /// The library owner's ORCID iD (`0000-0003-1419-2405`): their public
    /// profile is served at `/<orcid>`.
    pub orcid: Option<String>,
    /// Sign-in through an OpenID Connect provider, beside the API keys.
    pub oidc: Option<crate::oidc::OidcConfig>,
    /// Whether an API key can be typed into the web pages to sign in or approve
    /// a Zotero login. The data API takes keys regardless.
    pub key_login: bool,
    /// The ORCID iDs of the admins, who invite people and manage users. Without
    /// any, the owner is the admin.
    pub admin_orcids: Vec<String>,
    /// Where someone without access is told to ask for it.
    pub admin_email: Option<String>,
    /// A user's storage quota in bytes, unless an admin set theirs. The owner
    /// has none unless set.
    pub default_quota: u64,
    /// The server-side commonmeta sync, when a commonmeta store is configured.
    pub sync: Option<crate::sync::SyncConfig>,
}

/// An authorized upload, between the authorization, upload and registration
/// steps of the file protocol.
#[derive(Debug, Clone)]
pub struct PendingUpload {
    /// The library it was authorized for, and only it may register it.
    pub library: LibraryRef,
    pub item_key: String,
    pub md5: String,
    pub filename: String,
    pub filesize: i64,
    pub mtime: i64,
    pub content_type: Option<String>,
    /// The md5 of the ZIP Zotero sends instead of the file itself (snapshots and
    /// other multi-file attachments); `md5` and `filesize` then describe the
    /// attachment's main file and the ZIP respectively, as Zotero sends them.
    pub zip_md5: Option<String>,
    /// Set once the bytes were received and verified.
    pub uploaded: bool,
}

/// An authorized download through the server.
#[derive(Debug, Clone)]
pub struct PendingDownload {
    pub library: LibraryRef,
    pub item_key: String,
    /// Who asked for it: in a group, they must still be a member when it is
    /// fetched.
    pub user_id: u64,
}

pub struct App {
    pub cfg: Config,
    /// Users and what is known about each library.
    pub control: Arc<Control>,
    /// Each library's own database.
    pub libraries: Libraries,
    pub storage: Arc<Storage>,
    /// Scheduled backups, when they are on.
    pub backup: std::sync::OnceLock<Arc<crate::backup::Backup>>,
    pub keys: Keys,
    pub server_id: String,
    pub sessions: Tokens<LoginSession>,
    /// Signed-in web UI sessions, by the token in their cookie.
    pub ui_sessions: Tokens<auth::UiSession>,
    /// OpenID Connect sign-ins in progress, by their state.
    pub oidc_logins: Tokens<crate::oidc::PendingLogin>,
    /// People signed in who still have to connect a zotero.org account.
    pub pending_accounts: Tokens<ui::PendingAccount>,
    /// The OpenID Connect provider's endpoints, once read.
    pub oidc_provider: tokio::sync::OnceCell<crate::oidc::Provider>,
    pub uploads: Tokens<PendingUpload>,
    /// Proxied download token → what it downloads.
    pub downloads: Tokens<PendingDownload>,
    pub http: reqwest::Client,
    /// The owner's ORCID record, for the public profile.
    pub person: crate::profile::PersonCache,
    /// The commonmeta sync running now.
    pub sync: crate::sync::SyncState,
}

pub type AppState = Arc<App>;

impl App {
    /// The owner's own library.
    #[cfg(test)]
    pub async fn owner_library(&self) -> Result<Arc<Library>, crate::error::AppError> {
        self.libraries.get(LibraryRef::User(self.cfg.user_id)).await
    }

    /// A user's own library.
    pub async fn user_library(&self, user: &User) -> Result<Arc<Library>, crate::error::AppError> {
        self.libraries.get(LibraryRef::User(user.id)).await
    }

    /// Whether `user` is an admin: on `--admin-orcids`, or carrying an
    /// `--oidc-admin-values` value at their last sign-in, or the owner when
    /// neither is set.
    pub fn is_admin(&self, user: &User) -> bool {
        let provider_admins = self
            .access_rule()
            .is_some_and(|rule| !rule.admin_values.is_empty());
        if provider_admins && user.provider_admin {
            return true;
        }
        if self.cfg.admin_orcids.is_empty() && !provider_admins {
            return user.id == self.cfg.user_id;
        }
        self.is_admin_orcid(user.orcid.as_deref())
    }

    /// Whether the provider's access rule, not invitations, decides who may
    /// use the server.
    pub fn access_rule(&self) -> Option<&crate::oidc::AccessRule> {
        self.cfg.oidc.as_ref()?.access.as_ref()
    }

    pub fn is_admin_orcid(&self, orcid: Option<&str>) -> bool {
        orcid.is_some_and(|orcid| self.cfg.admin_orcids.iter().any(|a| a == orcid))
    }

    /// `user`'s storage quota in bytes; `None` for none.
    pub fn quota(&self, user: &User) -> Option<u64> {
        match user.quota_bytes {
            Some(bytes) => Some(bytes.max(0) as u64),
            None if user.id == self.cfg.user_id => None,
            None => Some(self.cfg.default_quota),
        }
    }

    pub async fn new(
        cfg: Config,
        control: Arc<Control>,
        libraries: Libraries,
        storage: Storage,
        keys: Keys,
    ) -> Result<AppState, crate::error::AppError> {
        let server_id = control.server_id().await?;
        control.reconcile(cfg.user_id, cfg.orcid.clone()).await?;
        libraries.drop_old_groups(&storage).await?;
        let http = reqwest::Client::builder()
            .user_agent(concat!("defter/", env!("CARGO_PKG_VERSION")))
            .timeout(Duration::from_secs(60))
            .build()
            .map_err(|e| crate::error::AppError::Internal(format!("http client: {e}")))?;
        Ok(Arc::new(App {
            cfg,
            control,
            libraries,
            storage: Arc::new(storage),
            backup: Default::default(),
            keys,
            server_id,
            sessions: Tokens::new(auth::SESSION_TTL),
            ui_sessions: Tokens::new(auth::UI_SESSION_TTL),
            oidc_logins: Tokens::new(crate::oidc::LOGIN_TTL),
            pending_accounts: Tokens::new(ui::ACCOUNT_TTL),
            oidc_provider: Default::default(),
            uploads: Tokens::new(UPLOAD_TTL),
            downloads: Tokens::new(DOWNLOAD_TTL),
            http,
            person: Default::default(),
            sync: Default::default(),
        }))
    }
}

pub fn router(app: AppState) -> Router {
    // The Zotero API is served at the root, where the Zotero client expects a
    // sync server, and under /api, where commonmeta sync expects the local
    // API (`http://localhost:23119/api`).
    let mut data = Router::new();
    let mut public = Router::new();
    for prefix in ["", "/api"] {
        data = zotero::data_routes(data, prefix);
        public = zotero::public_routes(public, prefix);
    }
    let data = data
        .layer(DefaultBodyLimit::max(MAX_JSON_BODY))
        .layer(middleware::from_fn(gunzip))
        .layer(middleware::from_fn_with_state(
            app.clone(),
            auth::require_key,
        ))
        .layer(CompressionLayer::new());

    Router::new()
        .merge(data)
        .merge(public)
        .merge(zotero::file_routes())
        .merge(
            ui::routes()
                .layer(middleware::from_fn_with_state(app.clone(), ui::language))
                .layer(CompressionLayer::new()),
        )
        .layer(middleware::from_fn(log_requests))
        .with_state(app)
}

/// Decode a gzip request body: Zotero compresses its writes.
async fn gunzip(req: Request, next: Next) -> Response {
    let gzipped = req
        .headers()
        .get("content-encoding")
        .and_then(|v| v.to_str().ok())
        .is_some_and(|e| e.eq_ignore_ascii_case("gzip"));
    if !gzipped {
        return next.run(req).await;
    }
    let (mut parts, body) = req.into_parts();
    let Ok(raw) = axum::body::to_bytes(body, MAX_JSON_BODY).await else {
        return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response();
    };
    let decoded = tokio::task::spawn_blocking(move || {
        use std::io::Read;
        // Cap the decoded size too, so a small body cannot expand unbounded.
        let mut out = Vec::new();
        flate2::read::GzDecoder::new(&raw[..])
            .take(MAX_JSON_BODY as u64 + 1)
            .read_to_end(&mut out)
            .map(|_| out)
    })
    .await;
    match decoded {
        Ok(Ok(out)) if out.len() <= MAX_JSON_BODY => {
            parts.headers.remove("content-encoding");
            parts.headers.remove("content-length");
            next.run(Request::from_parts(parts, Body::from(Bytes::from(out))))
                .await
        }
        Ok(Ok(_)) => (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response(),
        _ => (StatusCode::BAD_REQUEST, "malformed gzip body").into_response(),
    }
}

async fn log_requests(req: Request, next: Next) -> Response {
    let method = req.method().clone();
    let path = req.uri().path().to_string();
    let started = Instant::now();
    let response = next.run(req).await;
    let status = response.status().as_u16();
    let ms = started.elapsed().as_millis() as u64;
    if response.status().is_server_error() {
        tracing::error!(%method, path, status, ms, "request");
    } else if response.status().is_client_error() {
        tracing::warn!(%method, path, status, ms, "request");
    } else {
        tracing::info!(%method, path, status, ms, "request");
    }
    response
}