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,
}
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);
}
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");
}