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(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_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_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");
}
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");
}
#[cfg(test)]
mod tests {
use super::*;
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)
);
}
}