use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::{
Router,
body::{Body, Bytes},
extract::{DefaultBodyLimit, Request},
http::StatusCode,
middleware::{self, Next},
response::{IntoResponse, Response},
};
use tower_http::compression::CompressionLayer;
use crate::auth::{self, Keys, LoginSession, Tokens};
use crate::control::{Control, LibraryRef};
use crate::registry::{Libraries, Library};
use crate::storage::Storage;
use crate::{ui, zotero};
pub const MAX_JSON_BODY: usize = 64 * 1024 * 1024;
const UPLOAD_TTL: Duration = Duration::from_secs(3600);
const DOWNLOAD_TTL: Duration = Duration::from_secs(300);
#[derive(Clone)]
pub struct Config {
pub user_id: u64,
pub username: String,
pub public_url: String,
pub login_authorized_user: Option<String>,
pub max_upload: u64,
pub schema_url: String,
pub orcid: Option<String>,
pub oidc: Option<crate::oidc::OidcConfig>,
pub key_login: bool,
}
#[derive(Debug, Clone)]
pub struct PendingUpload {
pub library: LibraryRef,
pub item_key: String,
pub md5: String,
pub filename: String,
pub filesize: i64,
pub mtime: i64,
pub content_type: Option<String>,
pub zip_md5: Option<String>,
pub uploaded: bool,
}
#[derive(Debug, Clone)]
pub struct PendingDownload {
pub library: LibraryRef,
pub item_key: String,
}
pub struct App {
pub cfg: Config,
pub control: Arc<Control>,
pub libraries: Libraries,
pub storage: Arc<Storage>,
pub backup: std::sync::OnceLock<Arc<crate::backup::Backup>>,
pub keys: Keys,
pub server_id: String,
pub sessions: Tokens<LoginSession>,
pub ui_sessions: Tokens<auth::UiSession>,
pub oidc_logins: Tokens<crate::oidc::PendingLogin>,
pub oidc_provider: tokio::sync::OnceCell<crate::oidc::Provider>,
pub uploads: Tokens<PendingUpload>,
pub downloads: Tokens<PendingDownload>,
pub http: reqwest::Client,
pub person: crate::profile::PersonCache,
}
pub type AppState = Arc<App>;
impl App {
pub async fn owner_library(&self) -> Result<Arc<Library>, crate::error::AppError> {
self.libraries.get(LibraryRef::User(self.cfg.user_id)).await
}
pub async fn new(
cfg: Config,
control: Arc<Control>,
libraries: Libraries,
storage: Storage,
keys: Keys,
) -> Result<AppState, crate::error::AppError> {
let server_id = control.server_id().await?;
let http = reqwest::Client::builder()
.user_agent(concat!("defter/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(60))
.build()
.map_err(|e| crate::error::AppError::Internal(format!("http client: {e}")))?;
Ok(Arc::new(App {
cfg,
control,
libraries,
storage: Arc::new(storage),
backup: Default::default(),
keys,
server_id,
sessions: Tokens::new(auth::SESSION_TTL),
ui_sessions: Tokens::new(auth::UI_SESSION_TTL),
oidc_logins: Tokens::new(crate::oidc::LOGIN_TTL),
oidc_provider: Default::default(),
uploads: Tokens::new(UPLOAD_TTL),
downloads: Tokens::new(DOWNLOAD_TTL),
http,
person: Default::default(),
}))
}
}
pub fn router(app: AppState) -> Router {
let mut data = Router::new();
let mut public = Router::new();
for prefix in ["", "/api"] {
data = zotero::data_routes(data, prefix);
public = zotero::public_routes(public, prefix);
}
let data = data
.layer(DefaultBodyLimit::max(MAX_JSON_BODY))
.layer(middleware::from_fn(gunzip))
.layer(middleware::from_fn_with_state(
app.clone(),
auth::require_key,
))
.layer(CompressionLayer::new());
Router::new()
.merge(data)
.merge(public)
.merge(zotero::file_routes())
.merge(
ui::routes()
.layer(middleware::from_fn_with_state(app.clone(), ui::language))
.layer(CompressionLayer::new()),
)
.layer(middleware::from_fn(log_requests))
.with_state(app)
}
async fn gunzip(req: Request, next: Next) -> Response {
let gzipped = req
.headers()
.get("content-encoding")
.and_then(|v| v.to_str().ok())
.is_some_and(|e| e.eq_ignore_ascii_case("gzip"));
if !gzipped {
return next.run(req).await;
}
let (mut parts, body) = req.into_parts();
let Ok(raw) = axum::body::to_bytes(body, MAX_JSON_BODY).await else {
return (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response();
};
let decoded = tokio::task::spawn_blocking(move || {
use std::io::Read;
let mut out = Vec::new();
flate2::read::GzDecoder::new(&raw[..])
.take(MAX_JSON_BODY as u64 + 1)
.read_to_end(&mut out)
.map(|_| out)
})
.await;
match decoded {
Ok(Ok(out)) if out.len() <= MAX_JSON_BODY => {
parts.headers.remove("content-encoding");
parts.headers.remove("content-length");
next.run(Request::from_parts(parts, Body::from(Bytes::from(out))))
.await
}
Ok(Ok(_)) => (StatusCode::PAYLOAD_TOO_LARGE, "request body too large").into_response(),
_ => (StatusCode::BAD_REQUEST, "malformed gzip body").into_response(),
}
}
async fn log_requests(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let started = Instant::now();
let response = next.run(req).await;
let status = response.status().as_u16();
let ms = started.elapsed().as_millis() as u64;
if response.status().is_server_error() {
tracing::error!(%method, path, status, ms, "request");
} else if response.status().is_client_error() {
tracing::warn!(%method, path, status, ms, "request");
} else {
tracing::info!(%method, path, status, ms, "request");
}
response
}