defter 0.1.0

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! defter: a self-hosted Zotero sync and file storage server.
//!
//! Library metadata lives in SQLite, attachment files in S3-compatible object
//! storage. Stock Zotero clients sync against it, and commonmeta sync can
//! enrich the library through the same API.

use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;

use clap::{Args, Parser, Subcommand};

#[cfg(test)]
mod api_tests;
mod app;
mod auth;
mod db;
mod error;
mod query;
mod storage;
mod store;
mod ui;
mod zotero;

#[derive(Parser)]
#[command(
    name = "defter",
    version,
    about = "Self-hosted Zotero sync and file storage server"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Start the server (runs in the foreground).
    Start(Box<StartArgs>),
    /// Print the read/write API key the login session hands out, generating
    /// one in the data directory when none is configured.
    Key(KeyCommandArgs),
}

#[derive(Args)]
struct DataArgs {
    /// Directory for the database, the generated API key and (without S3)
    /// attachment files. Defaults to ~/Library/Application Support/defter on
    /// macOS and /var/lib/defter on Linux.
    #[arg(long, env = "DEFTER_DATA_DIR")]
    data_dir: Option<PathBuf>,
}

#[derive(Args)]
struct KeyArgs {
    /// API keys as a comma-separated list of `rw:<file>` / `ro:<file>`
    /// entries, each file holding one token. Read-only keys can read
    /// everything but change nothing.
    #[arg(long, env = "DEFTER_KEYS")]
    keys: Option<String>,

    /// A file holding a single read/write API key.
    #[arg(long, env = "DEFTER_API_KEY_FILE")]
    api_key_file: Option<PathBuf>,

    /// A single read/write API key (prefer --api-key-file).
    #[arg(long, env = "DEFTER_API_KEY", hide_env_values = true)]
    api_key: Option<String>,
}

#[derive(Args)]
struct KeyCommandArgs {
    #[command(flatten)]
    data: DataArgs,
    #[command(flatten)]
    keys: KeyArgs,
}

#[derive(Args)]
struct StartArgs {
    #[command(flatten)]
    data: DataArgs,
    #[command(flatten)]
    keys: KeyArgs,

    /// Address to listen on. Keep the default behind a reverse proxy.
    #[arg(long, env = "DEFTER_HOST", default_value = "127.0.0.1")]
    host: String,

    /// TCP port to listen on.
    #[arg(short, long, env = "DEFTER_PORT", default_value_t = 8189)]
    port: u16,

    /// URL clients reach the server at (the reverse proxy's), used in login,
    /// upload and download links. Defaults to http://<host>:<port>.
    #[arg(long, env = "DEFTER_PUBLIC_URL")]
    public_url: Option<String>,

    /// SQLite database file. Defaults to <data-dir>/defter.sqlite3.
    #[arg(long, env = "DEFTER_DB")]
    db: Option<PathBuf>,

    /// The library's Zotero user ID.
    #[arg(long, env = "DEFTER_USER_ID", default_value_t = 1)]
    user_id: u64,

    /// The user name reported to clients.
    #[arg(long, env = "DEFTER_USERNAME", default_value = "defter")]
    username: String,

    /// Require the SSO proxy in front of /login to forward this identity
    /// (X-Forwarded-Email/-User or X-Auth-Request-Email/-User) to approve a
    /// login. Leave unset on a private network.
    #[arg(long, env = "DEFTER_LOGIN_AUTHORIZED_USER")]
    login_authorized_user: Option<String>,

    /// Largest attachment file accepted, in MiB.
    #[arg(long, env = "DEFTER_MAX_UPLOAD_MB", default_value_t = 1024)]
    max_upload_mb: u64,

    /// S3 bucket for attachment files. Without it files are kept in
    /// --files-dir, which is meant for development.
    #[arg(long, env = "DEFTER_S3_BUCKET")]
    s3_bucket: Option<String>,

    /// S3 endpoint, e.g. https://<account>.r2.cloudflarestorage.com or
    /// http://127.0.0.1:9000 (defaults to AWS). Clients download from
    /// pre-signed URLs on it, so they must be able to reach it.
    #[arg(long, env = "DEFTER_S3_ENDPOINT")]
    s3_endpoint: Option<String>,

    /// S3 region (`auto` for R2). Falls back to AWS_REGION.
    #[arg(long, env = "DEFTER_S3_REGION")]
    s3_region: Option<String>,

    /// S3 access key ID. Falls back to AWS_ACCESS_KEY_ID.
    #[arg(long, env = "DEFTER_S3_ACCESS_KEY_ID")]
    s3_access_key_id: Option<String>,

    /// File holding the S3 access key ID.
    #[arg(long, env = "DEFTER_S3_ACCESS_KEY_ID_FILE")]
    s3_access_key_id_file: Option<PathBuf>,

    /// S3 secret access key (prefer the _FILE variant). Falls back to
    /// AWS_SECRET_ACCESS_KEY.
    #[arg(long, env = "DEFTER_S3_SECRET_ACCESS_KEY", hide_env_values = true)]
    s3_secret_access_key: Option<String>,

    /// File holding the S3 secret access key.
    #[arg(long, env = "DEFTER_S3_SECRET_ACCESS_KEY_FILE")]
    s3_secret_access_key_file: Option<PathBuf>,

    /// Address the bucket as <bucket>.<endpoint> instead of <endpoint>/<bucket>.
    #[arg(long, env = "DEFTER_S3_VIRTUAL_HOSTED_STYLE")]
    s3_virtual_hosted_style: bool,

    /// Key prefix for every object, so a bucket can be shared.
    #[arg(long, env = "DEFTER_S3_PREFIX", default_value = "")]
    s3_prefix: String,

    /// Seconds a pre-signed download URL stays valid.
    #[arg(long, env = "DEFTER_PRESIGN_TTL", default_value_t = 300)]
    presign_ttl: u64,

    /// Stream downloads through the server instead of redirecting clients
    /// to the bucket (for a bucket clients cannot reach).
    #[arg(long, env = "DEFTER_PROXY_DOWNLOADS")]
    proxy_downloads: bool,

    /// Directory for attachment files when no S3 bucket is configured.
    /// Defaults to <data-dir>/files.
    #[arg(long, env = "DEFTER_FILES_DIR")]
    files_dir: Option<PathBuf>,

    /// Where to get Zotero's item schema for GET /schema.
    #[arg(
        long,
        env = "DEFTER_SCHEMA_URL",
        default_value = "https://api.zotero.org/schema"
    )]
    schema_url: String,
}

fn platform_data_dir() -> PathBuf {
    #[cfg(target_os = "macos")]
    {
        let home = std::env::var("HOME").unwrap_or_default();
        PathBuf::from(format!("{home}/Library/Application Support/defter"))
    }
    #[cfg(target_os = "linux")]
    {
        PathBuf::from("/var/lib/defter")
    }
    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    {
        PathBuf::from("defter-data")
    }
}

impl DataArgs {
    fn dir(&self) -> PathBuf {
        self.data_dir.clone().unwrap_or_else(platform_data_dir)
    }
}

impl KeyArgs {
    fn source(&self, data_dir: &std::path::Path) -> auth::KeySource {
        auth::KeySource {
            manifest: self.keys.clone(),
            file: self.api_key_file.clone(),
            key: self.api_key.clone(),
            generated: data_dir.join("api-key"),
        }
    }
}

fn fail(message: impl std::fmt::Display) -> ! {
    eprintln!("defter: {message}");
    std::process::exit(1);
}

/// A secret from a file (preferred) or a value.
fn secret(file: &Option<PathBuf>, value: &Option<String>) -> Option<String> {
    match file {
        Some(path) => Some(
            std::fs::read_to_string(path)
                .unwrap_or_else(|e| fail(format!("read {}: {e}", path.display())))
                .trim()
                .to_string(),
        ),
        None => value.clone(),
    }
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "defter=info,tower_http=warn".into()),
        )
        .init();
    // object_store and reqwest bring rustls with aws-lc-rs, commonmeta with
    // ring; with both compiled in rustls cannot choose, so choose for it.
    let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();

    match Cli::parse().command {
        Command::Start(args) => start(*args).await,
        Command::Key(args) => {
            let (keys, generated) =
                auth::Keys::load(&args.keys.source(&args.data.dir())).unwrap_or_else(|e| fail(e));
            if let Some(path) = generated {
                eprintln!("defter: generated a key in {}", path.display());
            }
            println!("{}", keys.login_key());
        }
    }
}

async fn start(args: StartArgs) {
    let data_dir = args.data.dir();
    let (keys, generated) =
        auth::Keys::load(&args.keys.source(&data_dir)).unwrap_or_else(|e| fail(e));
    if let Some(path) = generated {
        tracing::warn!(path = %path.display(), "no API key configured; generated one");
    }

    let backend = match &args.s3_bucket {
        Some(bucket) => storage::Backend::S3(storage::S3Config {
            bucket: bucket.clone(),
            endpoint: args.s3_endpoint.clone(),
            region: args.s3_region.clone(),
            access_key_id: secret(&args.s3_access_key_id_file, &args.s3_access_key_id),
            secret_access_key: secret(&args.s3_secret_access_key_file, &args.s3_secret_access_key),
            virtual_hosted_style: args.s3_virtual_hosted_style,
            prefix: args.s3_prefix.clone(),
        }),
        None => {
            let dir = args
                .files_dir
                .clone()
                .unwrap_or_else(|| data_dir.join("files"));
            tracing::warn!(dir = %dir.display(), "no S3 bucket configured; keeping attachment files on local disk");
            storage::Backend::Local(dir)
        }
    };
    let storage = storage::Storage::new(
        &backend,
        Duration::from_secs(args.presign_ttl),
        args.proxy_downloads,
    )
    .unwrap_or_else(|e| fail(format!("object storage: {e}")));

    let db_path = args
        .db
        .clone()
        .unwrap_or_else(|| data_dir.join("defter.sqlite3"));
    let db = db::Db::open(&db_path).unwrap_or_else(|e| fail(format!("{}: {e}", db_path.display())));

    let addr: SocketAddr = format!("{}:{}", args.host, args.port)
        .parse()
        .unwrap_or_else(|e| fail(format!("listen address: {e}")));
    let public_url = args
        .public_url
        .clone()
        .unwrap_or_else(|| format!("http://{addr}"))
        .trim_end_matches('/')
        .to_string();
    let cfg = app::Config {
        user_id: args.user_id,
        username: args.username.clone(),
        public_url: public_url.clone(),
        login_authorized_user: args.login_authorized_user.clone().filter(|s| !s.is_empty()),
        max_upload: args.max_upload_mb.saturating_mul(1024 * 1024),
        schema_url: args.schema_url.clone(),
    };
    let app = app::App::new(cfg, db, storage, keys)
        .await
        .unwrap_or_else(|e| fail(e));

    let listener = tokio::net::TcpListener::bind(addr)
        .await
        .unwrap_or_else(|e| fail(format!("bind {addr}: {e}")));
    tracing::info!(
        %addr,
        public_url,
        db = %db_path.display(),
        storage = app.storage.description(),
        "defter listening"
    );
    axum::serve(listener, app::router(app))
        .with_graceful_shutdown(shutdown())
        .await
        .unwrap_or_else(|e| fail(format!("server: {e}")));
}

async fn shutdown() {
    let ctrl_c = async {
        let _ = tokio::signal::ctrl_c().await;
    };
    #[cfg(unix)]
    let terminate = async {
        if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
        {
            s.recv().await;
        }
    };
    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();
    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
    tracing::info!("shutting down");
}