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 backup;
mod countries;
mod db;
mod error;
mod i18n;
mod oidc;
mod profile;
mod query;
mod serve;
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(Box<StartArgs>),
Key(KeyCommandArgs),
Backup(Box<BackupCommandArgs>),
Restore(Box<RestoreCommandArgs>),
}
#[derive(Args)]
struct DataArgs {
#[arg(long, env = "DEFTER_DATA_DIR")]
data_dir: Option<PathBuf>,
}
#[derive(Args)]
struct KeyArgs {
#[arg(long, env = "DEFTER_KEYS")]
keys: Option<String>,
#[arg(long, env = "DEFTER_API_KEY_FILE")]
api_key_file: Option<PathBuf>,
#[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 StorageArgs {
#[arg(long, env = "DEFTER_S3_BUCKET")]
s3_bucket: Option<String>,
#[arg(long, env = "DEFTER_S3_ENDPOINT")]
s3_endpoint: Option<String>,
#[arg(long, env = "DEFTER_S3_REGION")]
s3_region: Option<String>,
#[arg(long, env = "DEFTER_S3_ACCESS_KEY_ID")]
s3_access_key_id: Option<String>,
#[arg(long, env = "DEFTER_S3_ACCESS_KEY_ID_FILE")]
s3_access_key_id_file: Option<PathBuf>,
#[arg(long, env = "DEFTER_S3_SECRET_ACCESS_KEY", hide_env_values = true)]
s3_secret_access_key: Option<String>,
#[arg(long, env = "DEFTER_S3_SECRET_ACCESS_KEY_FILE")]
s3_secret_access_key_file: Option<PathBuf>,
#[arg(long, env = "DEFTER_S3_VIRTUAL_HOSTED_STYLE")]
s3_virtual_hosted_style: bool,
#[arg(long, env = "DEFTER_S3_PREFIX", default_value = "")]
s3_prefix: String,
#[arg(long, env = "DEFTER_PRESIGN_TTL", default_value_t = 300)]
presign_ttl: u64,
#[arg(long, env = "DEFTER_PROXY_DOWNLOADS")]
proxy_downloads: bool,
#[arg(long, env = "DEFTER_FILES_DIR")]
files_dir: Option<PathBuf>,
}
#[derive(Args)]
struct LibraryArgs {
#[arg(long, env = "DEFTER_DB")]
db: Option<PathBuf>,
#[arg(long, env = "DEFTER_USER_ID", default_value_t = 1)]
user_id: u64,
}
#[derive(Args)]
struct BackupArgs {
#[arg(long, env = "DEFTER_BACKUP_INTERVAL_HOURS", default_value_t = 6)]
backup_interval_hours: u64,
#[arg(long, env = "DEFTER_BACKUP_KEEP_DAYS", default_value_t = 30)]
backup_keep_days: u64,
#[arg(long, env = "DEFTER_BACKUP_S3_BUCKET")]
backup_s3_bucket: Option<String>,
}
#[derive(Args)]
struct BackupCommandArgs {
#[command(flatten)]
data: DataArgs,
#[command(flatten)]
library: LibraryArgs,
#[command(flatten)]
storage: StorageArgs,
#[command(flatten)]
backup: BackupArgs,
#[arg(long)]
list: bool,
}
#[derive(Args)]
struct RestoreCommandArgs {
#[command(flatten)]
data: DataArgs,
#[command(flatten)]
library: LibraryArgs,
#[command(flatten)]
storage: StorageArgs,
#[command(flatten)]
backup: BackupArgs,
#[arg(long)]
manifest: Option<String>,
#[arg(long)]
yes: bool,
#[arg(long)]
min_version: Option<i64>,
}
#[derive(Args)]
struct StartArgs {
#[command(flatten)]
data: DataArgs,
#[command(flatten)]
keys: KeyArgs,
#[command(flatten)]
library: LibraryArgs,
#[command(flatten)]
storage: StorageArgs,
#[command(flatten)]
backup: BackupArgs,
#[arg(long, env = "DEFTER_HOST", default_value = "127.0.0.1")]
host: String,
#[arg(short, long, env = "DEFTER_PORT", default_value_t = 8189)]
port: u16,
#[arg(long, env = "DEFTER_TLS_CERT", requires = "tls_key")]
tls_cert: Option<PathBuf>,
#[arg(long, env = "DEFTER_TLS_KEY", requires = "tls_cert")]
tls_key: Option<PathBuf>,
#[arg(
long,
env = "DEFTER_DOMAIN",
requires = "email",
conflicts_with = "tls_cert"
)]
domain: Option<String>,
#[arg(long, env = "DEFTER_EMAIL")]
email: Option<String>,
#[arg(long, env = "DEFTER_ACME_CACHE")]
acme_cache: Option<PathBuf>,
#[arg(long, env = "DEFTER_ACME_STAGING")]
acme_staging: bool,
#[arg(long, env = "DEFTER_PUBLIC_URL")]
public_url: Option<String>,
#[arg(long, env = "DEFTER_USERNAME", default_value = "defter")]
username: String,
#[arg(long, env = "DEFTER_LOGIN_AUTHORIZED_USER")]
login_authorized_user: Option<String>,
#[arg(long, env = "DEFTER_MAX_UPLOAD_MB", default_value_t = 1024)]
max_upload_mb: u64,
#[arg(long, env = "DEFTER_ORCID")]
orcid: Option<String>,
#[arg(long, env = "DEFTER_OIDC_ISSUER")]
oidc_issuer: Option<String>,
#[arg(long, env = "DEFTER_OIDC_CLIENT_ID")]
oidc_client_id: Option<String>,
#[arg(long, env = "DEFTER_OIDC_CLIENT_SECRET", hide_env_values = true)]
oidc_client_secret: Option<String>,
#[arg(long, env = "DEFTER_OIDC_CLIENT_SECRET_FILE")]
oidc_client_secret_file: Option<String>,
#[arg(long, env = "DEFTER_OIDC_ALLOWED_ORCIDS")]
oidc_allowed_orcids: Option<String>,
#[arg(long, env = "DEFTER_OIDC_ORCID_CLAIM", default_value = "orcid")]
oidc_orcid_claim: String,
#[arg(long, env = "DEFTER_OIDC_NAME", default_value = "Keycloak")]
oidc_name: String,
#[arg(
long,
env = "DEFTER_SCHEMA_URL",
default_value = "https://api.zotero.org/schema"
)]
schema_url: String,
}
#[cfg(target_os = "macos")]
const SYSTEM_DATA_DIR: &str = "/Library/Application Support/defter";
#[cfg(not(target_os = "macos"))]
const SYSTEM_DATA_DIR: &str = "/var/lib/defter";
struct DataDirEnv {
state_directory: Option<String>,
root: bool,
home: Option<String>,
#[cfg_attr(target_os = "macos", allow(dead_code))]
xdg_data_home: Option<String>,
}
impl DataDirEnv {
fn current() -> Self {
let var = |name| std::env::var(name).ok().filter(|v: &String| !v.is_empty());
DataDirEnv {
state_directory: var("STATE_DIRECTORY"),
#[cfg(unix)]
root: unsafe { libc::geteuid() } == 0,
#[cfg(not(unix))]
root: false,
home: var("HOME"),
xdg_data_home: var("XDG_DATA_HOME"),
}
}
}
fn default_data_dir(env: &DataDirEnv) -> PathBuf {
if let Some(dir) = env
.state_directory
.as_deref()
.and_then(|dirs| dirs.split(':').next())
.filter(|dir| !dir.is_empty())
{
return PathBuf::from(dir);
}
if env.root {
return PathBuf::from(SYSTEM_DATA_DIR);
}
let home = || env.home.as_deref().map(PathBuf::from);
#[cfg(target_os = "macos")]
let user = home().map(|h| h.join("Library/Application Support"));
#[cfg(not(target_os = "macos"))]
let user = env
.xdg_data_home
.as_deref()
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| home().map(|h| h.join(".local/share")));
user.map(|dir| dir.join("defter"))
.unwrap_or_else(|| PathBuf::from(SYSTEM_DATA_DIR))
}
impl DataArgs {
fn dir(&self) -> PathBuf {
self.data_dir
.clone()
.unwrap_or_else(|| default_data_dir(&DataDirEnv::current()))
}
}
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);
}
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(),
}
}
impl StorageArgs {
fn backend(&self, data_dir: &std::path::Path) -> storage::Backend {
match &self.s3_bucket {
Some(bucket) => storage::Backend::S3(storage::S3Config {
bucket: bucket.clone(),
endpoint: self.s3_endpoint.clone(),
region: self.s3_region.clone(),
access_key_id: secret(&self.s3_access_key_id_file, &self.s3_access_key_id),
secret_access_key: secret(
&self.s3_secret_access_key_file,
&self.s3_secret_access_key,
),
virtual_hosted_style: self.s3_virtual_hosted_style,
prefix: self.s3_prefix.clone(),
}),
None => storage::Backend::Local(
self.files_dir
.clone()
.unwrap_or_else(|| data_dir.join("files")),
),
}
}
fn storage(&self, data_dir: &std::path::Path) -> storage::Storage {
storage::Storage::new(
&self.backend(data_dir),
Duration::from_secs(self.presign_ttl),
self.proxy_downloads,
)
.unwrap_or_else(|e| fail(format!("object storage: {e}")))
}
fn backup_storage(&self, data_dir: &std::path::Path, bucket: Option<&str>) -> storage::Storage {
let backend = match (
self.backend(data_dir),
bucket.filter(|b| !b.trim().is_empty()),
) {
(storage::Backend::S3(mut cfg), Some(bucket)) => {
cfg.bucket = bucket.to_string();
storage::Backend::S3(cfg)
}
(_, Some(_)) => fail(
"DEFTER_BACKUP_S3_BUCKET needs DEFTER_S3_BUCKET, whose settings it shares"
.to_string(),
),
(backend, None) => backend,
};
storage::Storage::new(&backend, Duration::from_secs(self.presign_ttl), true)
.unwrap_or_else(|e| fail(format!("backup storage: {e}")))
}
}
impl LibraryArgs {
fn path(&self, data_dir: &std::path::Path) -> PathBuf {
self.db
.clone()
.unwrap_or_else(|| data_dir.join("defter.sqlite3"))
}
fn source(&self, data_dir: &std::path::Path) -> backup::Source {
backup::Source {
name: format!("u{}", self.user_id),
path: self.path(data_dir),
}
}
}
fn backups(
data_dir: &std::path::Path,
library: &LibraryArgs,
storage_args: &StorageArgs,
backup_args: &BackupArgs,
files: std::sync::Arc<storage::Storage>,
) -> backup::Backup {
backup::Backup::new(
std::sync::Arc::new(
storage_args.backup_storage(data_dir, backup_args.backup_s3_bucket.as_deref()),
),
files,
vec![library.source(data_dir)],
data_dir.join("tmp"),
backup_args.backup_keep_days,
)
}
async fn backup_command(args: BackupCommandArgs) {
let data_dir = args.data.dir();
let files = std::sync::Arc::new(args.storage.storage(&data_dir));
let backup = backups(&data_dir, &args.library, &args.storage, &args.backup, files);
if args.list {
let manifests = backup::list(&backup.store)
.await
.unwrap_or_else(|e| fail(e));
if manifests.is_empty() {
println!("no backups in {}", backup.store.description());
}
for manifest in manifests {
let libraries = manifest
.entries
.iter()
.map(|e| format!("{} v{} ({} bytes)", e.name, e.version, e.bytes))
.collect::<Vec<_>>()
.join(", ");
println!(
"{} defter {} {libraries}",
manifest.created, manifest.defter
);
}
return;
}
let report = backup.run().await;
match report.error {
Some(error) => fail(error),
None => match report.manifest {
Some(manifest) => println!(
"backed up to {manifest}: {} snapshot(s), {} bytes",
report.snapshots, report.bytes
),
None => println!("nothing changed since the last backup"),
},
}
}
async fn restore_command(args: RestoreCommandArgs) {
let data_dir = args.data.dir();
let _lock = backup::lock(&data_dir).unwrap_or_else(|e| fail(e));
let files = std::sync::Arc::new(args.storage.storage(&data_dir));
let backup = backups(&data_dir, &args.library, &args.storage, &args.backup, files);
let source = args.library.source(&data_dir);
let restored = backup::restore(
&backup.store,
&backup.files,
&source,
args.manifest.as_deref(),
&data_dir.join("tmp"),
args.yes,
args.min_version,
)
.await
.unwrap_or_else(|e| fail(e));
println!(
"{} {} from {} (library version {}), with every version set to {}",
if args.yes {
"restored"
} else {
"would restore"
},
source.path.display(),
restored.manifest,
restored.snapshot_version,
restored.version
);
if let Some(previous) = &restored.previous {
println!("the replaced database is kept as {}", previous.display());
}
if args.yes {
println!(
"attachment files brought back: {}, still missing: {}",
restored.files_restored, restored.files_missing
);
println!(
"Zotero clients now see a newer library. Follow \"Backups and restore\" in the \
README to bring them in line."
);
} else {
println!("run again with --yes to restore");
}
}
#[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();
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());
}
Command::Backup(args) => backup_command(*args).await,
Command::Restore(args) => restore_command(*args).await,
}
}
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");
}
if keys.short_keys() > 0 {
tracing::warn!(
count = keys.short_keys(),
min = auth::MIN_KEY_LENGTH,
"API keys shorter than the minimum are easy to guess on the login page; \
generate keys with `openssl rand -hex 32`"
);
}
let _lock = backup::lock(&data_dir).unwrap_or_else(|e| fail(e));
if args.storage.s3_bucket.is_none() {
tracing::warn!("no S3 bucket configured; keeping attachment files on local disk");
}
let storage = args.storage.storage(&data_dir);
let db_path = args.library.path(&data_dir);
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 tls = match (&args.domain, &args.email) {
(Some(domain), Some(email)) => Some(serve::Tls::Acme {
domain: domain.clone(),
email: email.clone(),
cache: args
.acme_cache
.clone()
.unwrap_or_else(|| data_dir.join("acme")),
staging: args.acme_staging,
}),
_ => args
.tls_cert
.clone()
.zip(args.tls_key.clone())
.map(|(cert, key)| serve::Tls::Files { cert, key }),
};
let scheme = if tls.is_some() { "https" } else { "http" };
let public_url = args
.public_url
.clone()
.or_else(|| args.domain.as_ref().map(|d| format!("https://{d}")))
.unwrap_or_else(|| format!("{scheme}://{addr}"))
.trim_end_matches('/')
.to_string();
let cfg = app::Config {
user_id: args.library.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(),
orcid: args
.orcid
.as_deref()
.filter(|s| !s.trim().is_empty())
.map(|s| {
profile::normalize_orcid(s)
.unwrap_or_else(|| fail(format!("--orcid: {s:?} is not a valid ORCID iD")))
}),
oidc: args
.oidc_issuer
.as_deref()
.filter(|issuer| !issuer.trim().is_empty())
.map(|issuer| {
oidc::OidcConfig::new(
issuer,
args.oidc_client_id.as_deref().unwrap_or(""),
secret(
&args
.oidc_client_secret_file
.as_deref()
.filter(|file| !file.trim().is_empty())
.map(PathBuf::from),
&args.oidc_client_secret,
),
args.oidc_allowed_orcids.as_deref().unwrap_or(""),
&args.oidc_orcid_claim,
&args.oidc_name,
)
.unwrap_or_else(|e| fail(e))
}),
key_login: args
.oidc_issuer
.as_deref()
.is_none_or(|i| i.trim().is_empty())
|| args
.keys
.api_key
.as_deref()
.is_some_and(|key| !key.trim().is_empty()),
};
let app = app::App::new(cfg, db, storage, keys)
.await
.unwrap_or_else(|e| fail(e));
if args.backup.backup_interval_hours > 0 {
let backup = std::sync::Arc::new(backups(
&data_dir,
&args.library,
&args.storage,
&args.backup,
app.storage.clone(),
));
app.storage.keep_deleted(true);
let _ = app.backup.set(backup.clone());
let every = Duration::from_secs(args.backup.backup_interval_hours * 3600);
tokio::spawn(async move {
let mut ticks = tokio::time::interval_at(
tokio::time::Instant::now() + Duration::from_secs(60),
every,
);
ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticks.tick().await;
backup.run().await;
}
});
} else {
tracing::warn!("scheduled backups are off (--backup-interval-hours 0)");
}
let storage = app.storage.description().to_string();
let cert = match &tls {
Some(serve::Tls::Files { cert, .. }) => Some(cert.display().to_string()),
Some(serve::Tls::Acme { domain, .. }) => Some(format!("Let's Encrypt: {domain}")),
None => None,
};
serve::serve(addr, app::router(app), tls, shutdown(), move |addr| {
tracing::info!(
%addr,
scheme,
cert,
public_url,
db = %db_path.display(),
storage,
"defter listening"
);
})
.await
.unwrap_or_else(|e| fail(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");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oidc_settings_set_to_nothing_mean_off() {
let start = |args: &[&str]| {
let all = ["defter", "start"].iter().chain(args).copied();
Cli::try_parse_from(all).map(|_| ())
};
assert!(start(&["--oidc-issuer", ""]).is_ok());
assert!(start(&["--oidc-client-secret-file", ""]).is_ok());
}
#[test]
fn lets_encrypt_needs_an_email_and_excludes_certificate_files() {
let parse = |args: &[&str]| {
let all = ["defter", "start"].iter().chain(args).copied();
Cli::try_parse_from(all).map(|_| ())
};
const DOMAIN: &str = "defter.example.org";
assert!(parse(&["--domain", DOMAIN, "--email", "me@example.org"]).is_ok());
assert!(parse(&["--domain", DOMAIN]).is_err());
assert!(
parse(&[
"--domain",
DOMAIN,
"--email",
"me@example.org",
"--tls-cert",
"c.pem",
"--tls-key",
"k.pem",
])
.is_err()
);
}
fn env(state: Option<&str>, root: bool, home: Option<&str>, xdg: Option<&str>) -> DataDirEnv {
DataDirEnv {
state_directory: state.map(String::from),
root,
home: home.map(String::from),
xdg_data_home: xdg.map(String::from),
}
}
#[test]
fn systemd_state_directory_wins() {
let dir = default_data_dir(&env(
Some("/var/lib/defter:/var/lib/other"),
true,
None,
None,
));
assert_eq!(dir, PathBuf::from("/var/lib/defter"));
}
#[test]
fn root_uses_the_system_directory() {
let dir = default_data_dir(&env(None, true, Some("/root"), None));
assert_eq!(dir, PathBuf::from(SYSTEM_DATA_DIR));
}
#[cfg(target_os = "macos")]
#[test]
fn a_mac_user_gets_application_support() {
let dir = default_data_dir(&env(None, false, Some("/Users/me"), Some("/ignored")));
assert_eq!(
dir,
PathBuf::from("/Users/me/Library/Application Support/defter")
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn a_linux_user_gets_xdg_data_home() {
let xdg = default_data_dir(&env(None, false, Some("/home/me"), Some("/data")));
assert_eq!(xdg, PathBuf::from("/data/defter"));
let local = default_data_dir(&env(None, false, Some("/home/me"), Some("relative")));
assert_eq!(local, PathBuf::from("/home/me/.local/share/defter"));
}
#[test]
fn no_home_falls_back_to_the_system_directory() {
assert_eq!(
default_data_dir(&env(None, false, None, None)),
PathBuf::from(SYSTEM_DATA_DIR)
);
}
}