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 control;
mod countries;
mod db;
mod error;
mod i18n;
mod migrate;
mod oidc;
mod profile;
mod query;
mod registry;
mod serve;
mod storage;
mod store;
mod ui;
mod vocab;
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>),
Migrate(Box<MigrateCommandArgs>),
}
#[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 = "library", conflicts_with = "control")]
restore_library: Option<String>,
#[arg(long)]
control: bool,
#[arg(long)]
yes: bool,
#[arg(long)]
min_version: Option<i64>,
}
#[derive(Args)]
struct MigrateCommandArgs {
#[command(flatten)]
data: DataArgs,
#[command(flatten)]
library: LibraryArgs,
#[command(flatten)]
storage: StorageArgs,
#[command(flatten)]
backup: BackupArgs,
#[arg(long, env = "DEFTER_USERNAME", default_value = "defter")]
username: String,
#[arg(long, env = "DEFTER_ORCID")]
orcid: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_backup: bool,
}
#[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_LIBRARY_CACHE", default_value_t = 150)]
library_cache: usize,
#[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_ADMIN_ORCIDS")]
admin_orcids: Option<String>,
#[arg(long, env = "DEFTER_DEFAULT_QUOTA_MB", default_value_t = 2048)]
default_quota_mb: u64,
#[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 layout(&self, data_dir: &std::path::Path) -> migrate::Layout {
migrate::Layout::new(data_dir, self.path(data_dir))
}
}
fn sources(layout: &migrate::Layout, user_id: u64) -> Result<Vec<backup::Source>, String> {
match migrate::state(layout, user_id) {
migrate::State::New => Ok(Vec::new()),
migrate::State::Legacy => Ok(vec![backup::Source {
name: control::LibraryRef::User(user_id).to_string(),
path: layout.legacy.clone(),
files: Some(storage::FileScope::Legacy),
}]),
_ => {
let conn = rusqlite::Connection::open_with_flags(
&layout.control,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.map_err(|e| format!("opening {}: {e}", layout.control.display()))?;
conn.busy_timeout(Duration::from_secs(30))
.map_err(|e| e.to_string())?;
let libraries = control::libraries(&conn)
.map_err(|e| format!("reading {}: {e}", layout.control.display()))?;
let mut sources = vec![backup::Source {
name: "control".to_string(),
path: layout.control.clone(),
files: None,
}];
sources.extend(
libraries
.into_iter()
.map(|(library, files)| backup::Source {
name: library.to_string(),
path: layout.library(library),
files: Some(files),
}),
);
Ok(sources)
}
}
}
fn backups(
data_dir: &std::path::Path,
library: &LibraryArgs,
storage_args: &StorageArgs,
backup_args: &BackupArgs,
files: std::sync::Arc<storage::Storage>,
) -> backup::Backup {
let layout = library.layout(data_dir);
let user_id = library.user_id;
backup::Backup::new(
std::sync::Arc::new(
storage_args.backup_storage(data_dir, backup_args.backup_s3_bucket.as_deref()),
),
files,
std::sync::Arc::new(move || sources(&layout, user_id)),
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 layout = args.library.layout(&data_dir);
let name = if args.control {
"control".to_string()
} else {
args.restore_library
.clone()
.unwrap_or_else(|| control::LibraryRef::User(args.library.user_id).to_string())
};
let source = if args.control {
backup::Source {
name: name.clone(),
path: layout.control.clone(),
files: None,
}
} else {
if matches!(
migrate::state(&layout, args.library.user_id),
migrate::State::New | migrate::State::ControlMissing
) {
fail(
"restore the control database first (`defter restore --control`): it knows \
where each library's attachment files are",
);
}
backup
.sources()
.unwrap_or_else(|e| fail(e))
.into_iter()
.find(|s| s.name == name)
.unwrap_or_else(|| fail(format!("the control database has no library {name}")))
};
let served = match source.files {
Some(_) if layout.control.exists() => control::LibraryRef::parse(&name)
.and_then(|library| {
let conn = rusqlite::Connection::open_with_flags(
&layout.control,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
)
.ok()?;
control::version_high(&conn, library).ok()
})
.filter(|v| *v > 0),
_ => None,
};
let restored = backup::restore(
&backup.store,
&backup.files,
&source,
args.manifest.as_deref(),
&data_dir.join("tmp"),
args.yes,
args.min_version,
served,
)
.await
.unwrap_or_else(|e| fail(e));
let doing = if args.yes {
"restored"
} else {
"would restore"
};
if source.files.is_none() {
println!(
"{doing} {} from {}",
source.path.display(),
restored.manifest
);
} else {
println!(
"{doing} {} from {} (library version {}), with every version set to {}",
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 && source.files.is_none() {
println!("users, keys and groups are back as they were then");
} else 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");
}
}
async fn migrate_command(args: MigrateCommandArgs) {
let data_dir = args.data.dir();
let _lock = backup::lock(&data_dir).unwrap_or_else(|e| fail(e));
let layout = args.library.layout(&data_dir);
let orcid = |s: &str| {
profile::normalize_orcid(s).unwrap_or_else(|| fail(format!("{s:?} is not an ORCID iD")))
};
let owner = migrate::Owner {
user_id: args.library.user_id,
username: args.username.clone(),
orcid: args
.orcid
.as_deref()
.filter(|s| !s.trim().is_empty())
.map(orcid),
};
let plan = migrate::plan(&layout, &owner).unwrap_or_else(|e| fail(e));
println!(
"{} {} (library version {}) to {}",
if args.dry_run { "would move" } else { "moving" },
layout.legacy.display(),
plan.version,
layout
.library(control::LibraryRef::User(owner.user_id))
.display()
);
if plan.state == migrate::State::Legacy {
println!(
"and create {}: user {} ({}), profile {}{}, attachment files staying under files/",
layout.control.display(),
owner.user_id,
owner.username,
plan.orcid.as_deref().unwrap_or("none"),
if plan.profile_hidden { ", hidden" } else { "" },
);
}
if args.dry_run {
println!("run again without --dry-run to migrate");
return;
}
if plan.state == migrate::State::Legacy && !args.no_backup {
let files = std::sync::Arc::new(args.storage.storage(&data_dir));
let backup = backups(&data_dir, &args.library, &args.storage, &args.backup, files);
let report = backup.run().await;
if let Some(error) = report.error {
fail(format!(
"backing up first failed, so nothing was changed: {error}"
));
}
match report.manifest {
Some(manifest) => println!("backed up to {manifest}"),
None => println!("the last backup is current"),
}
}
migrate::apply(&layout, &owner, &plan).unwrap_or_else(|e| fail(e));
println!("migrated; start the server again");
}
#[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,
Command::Migrate(args) => migrate_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 layout = args.library.layout(&data_dir);
if let Some(refused) = migrate::refuse_start(&layout, args.library.user_id) {
fail(refused);
}
let control = std::sync::Arc::new(
control::Control::open(&layout.control)
.unwrap_or_else(|e| fail(format!("{}: {e}", layout.control.display()))),
);
control
.ensure_owner(registry::owner(args.library.user_id, &args.username, false))
.await
.unwrap_or_else(|e| fail(e));
let libraries = registry::Libraries::new(
layout.libraries.clone(),
control.clone(),
args.library_cache,
);
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_orcid_claim,
&args.oidc_name,
)
.unwrap_or_else(|e| fail(e))
}),
admin_orcids: args
.admin_orcids
.as_deref()
.unwrap_or("")
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
profile::normalize_orcid(s).unwrap_or_else(|| {
fail(format!("--admin-orcids: {s:?} is not a valid ORCID iD"))
})
})
.collect(),
default_quota: args.default_quota_mb.saturating_mul(1024 * 1024),
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, control, libraries, storage, keys)
.await
.unwrap_or_else(|e| fail(e));
app.libraries
.get(control::LibraryRef::User(args.library.user_id))
.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,
data = %data_dir.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)
);
}
}