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 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),
}
#[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 StartArgs {
#[command(flatten)]
data: DataArgs,
#[command(flatten)]
keys: KeyArgs,
#[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_DB")]
db: Option<PathBuf>,
#[arg(long, env = "DEFTER_USER_ID", default_value_t = 1)]
user_id: u64,
#[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_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>,
#[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(),
}
}
#[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());
}
}
}
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 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 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.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))
}),
};
let app = app::App::new(cfg, db, storage, keys)
.await
.unwrap_or_else(|e| fail(e));
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)
);
}
}