use std::collections::HashMap;
use std::sync::Arc;
use askama::Template;
use axum::{
Extension, Form, Json, Router,
extract::{Path, Query, RawQuery, Request, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
middleware::Next,
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
use serde_json::json;
use crate::app::AppState;
use crate::error::AppError;
use crate::i18n::{self, Lang, Pages, Text};
use crate::profile::{self, PersonView, WorkView};
use crate::{auth, oidc, store, zotero};
mod assets {
include!(concat!(env!("OUT_DIR"), "/ui_assets.rs"));
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(dashboard))
.route("/login", get(auth::login_page).post(auth::login_authorize))
.route("/assets/{name}", get(asset))
.route("/ui/status", get(status))
.route("/ui/session", post(sign_in))
.route("/ui/logout", post(sign_out))
.route("/ui/oidc/login", get(oidc_login))
.route("/ui/oidc/callback", get(oidc_callback))
.route("/ui/profile", post(profile_visibility))
.route("/{orcid}", get(profile_page))
.route("/{orcid}/export", get(profile_export))
}
const LANGUAGE_COOKIE: &str = "defter_lang";
fn request_lang(asked_for: Option<Lang>, headers: &HeaderMap) -> Lang {
asked_for
.or_else(|| auth::cookie(headers, LANGUAGE_COOKIE).and_then(Lang::parse))
.unwrap_or_else(|| {
let accept = headers.get(header::ACCEPT_LANGUAGE);
Lang::from_accept_language(accept.and_then(|v| v.to_str().ok()).unwrap_or(""))
})
}
fn query_lang(query: Option<&str>) -> Option<Lang> {
let params: Vec<(String, String)> =
serde_urlencoded::from_str(query.unwrap_or("")).unwrap_or_default();
params
.iter()
.find(|(k, _)| k == "lang")
.and_then(|(_, v)| Lang::parse(v))
}
pub async fn language(State(app): State<AppState>, mut request: Request, next: Next) -> Response {
let asked_for = query_lang(request.uri().query());
let lang = request_lang(asked_for, request.headers());
request.extensions_mut().insert(lang);
let mut response = next.run(request).await;
let is_page = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/html"));
let headers = response.headers_mut();
if is_page {
headers.append(header::VARY, HeaderValue::from_static("Cookie"));
headers.append(header::VARY, HeaderValue::from_static("Accept-Language"));
}
if let Some(picked) = asked_for {
let secure = if app.cfg.public_url.starts_with("https://") {
"; Secure"
} else {
""
};
let cookie = format!(
"{LANGUAGE_COOKIE}={}; Path=/; SameSite=Lax; Max-Age=31536000{secure}",
picked.code()
);
if let Ok(value) = HeaderValue::from_str(&cookie) {
headers.append(header::SET_COOKIE, value);
}
}
response
}
fn session(app: &AppState, headers: &HeaderMap) -> Option<auth::Access> {
auth::cookie(headers, auth::UI_SESSION_COOKIE)
.and_then(|t| app.ui_sessions.get(t))
.map(|session| session.access)
}
fn signed_in(app: &AppState, headers: &HeaderMap) -> bool {
session(app, headers).is_some()
}
fn authorized(app: &AppState, headers: &HeaderMap) -> bool {
signed_in(app, headers)
|| auth::request_key(headers).is_some_and(|k| app.keys.access(k).is_some())
}
fn session_cookie(app: &AppState, token: Option<&str>) -> HeaderValue {
let secure = if app.cfg.public_url.starts_with("https://") {
"; Secure"
} else {
""
};
let (value, max_age) = match token {
Some(token) => (token, auth::UI_SESSION_TTL.as_secs()),
None => ("", 0),
};
HeaderValue::from_str(&format!(
"{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}",
auth::UI_SESSION_COOKIE
))
.expect("cookie of hex and fixed attributes")
}
#[derive(Template)]
#[template(path = "signin.html")]
struct SignInPage<'a> {
layout: Layout,
t: &'static Pages,
error: Option<&'a str>,
oidc: Option<&'a str>,
key_login: bool,
}
fn sign_in_page(app: &AppState, status: StatusCode, error: Option<&str>, lang: Lang) -> Response {
let signin = SignInPage {
layout: Layout::new(false, lang),
t: lang.pages(),
error,
oidc: app.cfg.oidc.as_ref().map(|o| o.name.as_str()),
key_login: app.cfg.key_login,
};
let mut response = page(status, &signin.layout, signin.render());
let headers = response.headers_mut();
headers.extend(zotero::server_headers(&app.server_id));
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
async fn sign_in(
State(app): State<AppState>,
headers: HeaderMap,
Extension(lang): Extension<Lang>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
if auth::foreign_origin(&headers, &app.cfg.public_url) {
return sign_in_page(
&app,
StatusCode::FORBIDDEN,
Some(lang.pages().from_another_site),
lang,
);
}
if !app.cfg.key_login {
return sign_in_page(&app, StatusCode::FORBIDDEN, None, lang);
}
let key = form.get("key").map(|k| k.trim()).unwrap_or_default();
let Some(access) = app.keys.access(key) else {
tracing::warn!("web UI sign-in with a wrong key");
tokio::time::sleep(auth::WRONG_KEY_DELAY).await;
return sign_in_page(
&app,
StatusCode::FORBIDDEN,
Some(lang.pages().not_a_key),
lang,
);
};
let (token, _) = app.ui_sessions.insert(auth::UiSession {
access,
id_token: None,
});
let mut response = Redirect::to("/").into_response();
response
.headers_mut()
.insert(header::SET_COOKIE, session_cookie(&app, Some(&token)));
response
}
#[derive(Template)]
#[template(path = "signing-out.html")]
struct SigningOutPage {
layout: Layout,
t: &'static Pages,
url: String,
}
fn provider_logout_url(app: &AppState, session: &auth::UiSession) -> Option<String> {
oidc::logout_url(
app.oidc_provider.get()?,
app.cfg.oidc.as_ref()?,
session.id_token.as_ref()?,
&format!("{}/", app.cfg.public_url),
)
}
async fn sign_out(
State(app): State<AppState>,
headers: HeaderMap,
Extension(lang): Extension<Lang>,
) -> Response {
if auth::foreign_origin(&headers, &app.cfg.public_url) {
return StatusCode::FORBIDDEN.into_response();
}
let session = auth::cookie(&headers, auth::UI_SESSION_COOKIE)
.and_then(|token| app.ui_sessions.remove(token));
if let Some(url) = session.and_then(|s| provider_logout_url(&app, &s)) {
let signing_out = SigningOutPage {
layout: Layout::new(false, lang),
t: lang.pages(),
url,
};
let mut response = page(StatusCode::OK, &signing_out.layout, signing_out.render());
let headers = response.headers_mut();
headers.insert(header::SET_COOKIE, session_cookie(&app, None));
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
return response;
}
let mut response = Redirect::to("/").into_response();
response
.headers_mut()
.insert(header::SET_COOKIE, session_cookie(&app, None));
response
}
fn oidc_redirect_uri(app: &AppState) -> String {
format!("{}/ui/oidc/callback", app.cfg.public_url)
}
fn oidc_state_cookie(app: &AppState, state: Option<&str>) -> HeaderValue {
let secure = if app.cfg.public_url.starts_with("https://") {
"; Secure"
} else {
""
};
let (value, max_age) = match state {
Some(state) => (state, oidc::LOGIN_TTL.as_secs()),
None => ("", 0),
};
HeaderValue::from_str(&format!(
"{}={value}; Path=/ui/oidc; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}",
oidc::STATE_COOKIE
))
.expect("cookie of hex and fixed attributes")
}
async fn oidc_provider(app: &AppState, cfg: &oidc::OidcConfig) -> Result<oidc::Provider, String> {
app.oidc_provider
.get_or_try_init(|| oidc::discover(&app.http, cfg))
.await
.cloned()
}
fn oidc_failed(
app: &AppState,
status: StatusCode,
detail: &str,
message: &str,
lang: Lang,
) -> Response {
tracing::warn!(detail, "OpenID Connect sign-in failed");
let mut response = sign_in_page(app, status, Some(message), lang);
response
.headers_mut()
.insert(header::SET_COOKIE, oidc_state_cookie(app, None));
response
}
async fn oidc_login(
State(app): State<AppState>,
Extension(lang): Extension<Lang>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let Some(cfg) = &app.cfg.oidc else {
return StatusCode::NOT_FOUND.into_response();
};
let provider = match oidc_provider(&app, cfg).await {
Ok(provider) => provider,
Err(detail) => {
return oidc_failed(
&app,
StatusCode::BAD_GATEWAY,
&detail,
lang.pages().sign_in_failed,
lang,
);
}
};
let pending = oidc::PendingLogin {
nonce: auth::random_hex(16),
verifier: auth::random_hex(32),
login_session: params
.get("session")
.filter(|token| app.sessions.get(token).is_some())
.cloned(),
};
let (state, _) = app.oidc_logins.insert(pending.clone());
let url =
match oidc::authorization_url(&provider, cfg, &oidc_redirect_uri(&app), &state, &pending) {
Ok(url) => url,
Err(detail) => {
return oidc_failed(
&app,
StatusCode::INTERNAL_SERVER_ERROR,
&detail,
lang.pages().sign_in_failed,
lang,
);
}
};
let mut response = Redirect::to(&url).into_response();
response
.headers_mut()
.insert(header::SET_COOKIE, oidc_state_cookie(&app, Some(&state)));
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
async fn oidc_callback(
State(app): State<AppState>,
headers: HeaderMap,
Extension(lang): Extension<Lang>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let Some(cfg) = &app.cfg.oidc else {
return StatusCode::NOT_FOUND.into_response();
};
let t = lang.pages();
let forbidden =
|detail: &str| oidc_failed(&app, StatusCode::FORBIDDEN, detail, t.sign_in_failed, lang);
let Some(state) = params.get("state") else {
return forbidden("no state");
};
if auth::cookie(&headers, oidc::STATE_COOKIE) != Some(state.as_str()) {
return forbidden("the state is not this browser's");
}
if app.oidc_logins.get(state).is_none() {
return forbidden("unknown or expired state");
}
let Some(pending) = app.oidc_logins.remove(state) else {
return forbidden("state already used");
};
if let Some(error) = params.get("error") {
return forbidden(&format!("the provider says: {error}"));
}
let Some(code) = params.get("code") else {
return forbidden("no code");
};
let provider = match oidc_provider(&app, cfg).await {
Ok(provider) => provider,
Err(detail) => {
return oidc_failed(
&app,
StatusCode::BAD_GATEWAY,
&detail,
lang.pages().sign_in_failed,
lang,
);
}
};
let signed_in = match oidc::signed_in_orcid(
&app.http,
cfg,
&provider,
&oidc_redirect_uri(&app),
code,
&pending,
)
.await
{
Ok(signed_in) => signed_in,
Err(detail) => return forbidden(&detail),
};
let Some(orcid) = signed_in.orcid else {
return oidc_failed(
&app,
StatusCode::FORBIDDEN,
"no ORCID iD among the account's claims",
t.no_orcid,
lang,
);
};
if !cfg.allows(&orcid) {
return oidc_failed(
&app,
StatusCode::FORBIDDEN,
"the account's ORCID iD is not on the allowed list",
t.not_allowed,
lang,
);
}
if let Err(error) = profile::claim_ownership(&app, &orcid).await {
return error.into_response();
}
let (token, _) = app.ui_sessions.insert(auth::UiSession {
access: auth::Access { write: true },
id_token: Some(signed_in.id_token),
});
let back = match pending.login_session {
Some(login) => format!("/login?session={login}"),
None => "/".to_string(),
};
let mut response = Redirect::to(&back).into_response();
let headers = response.headers_mut();
headers.append(header::SET_COOKIE, session_cookie(&app, Some(&token)));
headers.append(header::SET_COOKIE, oidc_state_cookie(&app, None));
response
}
struct Layout {
stylesheet: &'static str,
script: &'static str,
signed_in: bool,
wide: bool,
text: &'static i18n::LayoutText,
lang: Lang,
languages: [Lang; 2],
lang_action: &'static str,
lang_fields: Vec<(&'static str, String)>,
nonce: String,
}
impl Layout {
fn new(signed_in: bool, lang: Lang) -> Self {
Layout {
stylesheet: assets::STYLESHEET,
script: assets::SCRIPT,
signed_in,
wide: false,
text: lang.layout(),
lang,
languages: Lang::ALL,
lang_action: "/",
lang_fields: Vec::new(),
nonce: auth::random_hex(16),
}
}
fn content_security_policy(&self) -> String {
format!(
"default-src 'none'; script-src 'self' 'nonce-{}'; style-src 'self'; \
img-src 'self' data:; connect-src 'self'; form-action 'self'; \
frame-ancestors 'none'; base-uri 'none'",
self.nonce
)
}
}
fn page(status: StatusCode, layout: &Layout, rendered: askama::Result<String>) -> Response {
let html = match rendered {
Ok(html) => html,
Err(error) => {
return AppError::Internal(format!("rendering a page: {error}")).into_response();
}
};
let mut response = (status, Html(html)).into_response();
let headers = response.headers_mut();
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
if let Ok(csp) = HeaderValue::from_str(&layout.content_security_policy()) {
headers.insert(header::CONTENT_SECURITY_POLICY, csp);
}
response
}
struct Stat {
label: &'static str,
value: String,
}
struct Status {
version: String,
user_id: u64,
defter_version: &'static str,
commonmeta_version: &'static str,
stats: Vec<Stat>,
storage_location: String,
signed_downloads: bool,
max_upload: String,
backup: String,
backup_failed: bool,
api_url: String,
}
impl Status {
fn new(app: &AppState, library: &store::LibraryStats, lang: Lang) -> Self {
let t = lang.pages();
let stat = |label, value| Stat { label, value };
let format_count = |n: i64| format_count(n, lang);
let format_bytes = |n: u64| lang.decimal(&format_bytes(n));
Status {
version: format_count(library.version),
user_id: app.cfg.user_id,
defter_version: env!("CARGO_PKG_VERSION"),
commonmeta_version: commonmeta::VERSION,
stats: vec![
stat(t.items, format_count(library.items)),
stat(t.attachments, format_count(library.attachments)),
stat(t.notes, format_count(library.notes)),
stat(t.collections, format_count(library.collections)),
stat(t.tags, format_count(library.tags)),
stat(t.in_trash, format_count(library.trashed)),
stat(t.files, format_count(library.files)),
stat(t.stored, format_bytes(library.file_bytes as u64)),
],
storage_location: app.storage.description().to_string(),
signed_downloads: app.storage.signs_downloads(),
backup: match app.backup.get().map(|b| b.last()) {
None => t.backup_off.to_string(),
Some(None) => t.backup_never.to_string(),
Some(Some(report)) => match &report.error {
Some(error) => format!("{} {error}", t.backup_failed),
None => {
let last =
format!("{} {}", t.backup_last, crate::backup::utc_text(report.at));
match &report.warning {
Some(warning) => {
format!("{last}. {} {warning}", t.backup_warning)
}
None => last,
}
}
},
},
backup_failed: app
.backup
.get()
.and_then(|b| b.last())
.is_some_and(|r| r.error.is_some() || r.warning.is_some()),
max_upload: format_bytes(app.cfg.max_upload),
api_url: format!("{}/api", app.cfg.public_url),
}
}
}
struct ProfileStatus {
url: String,
hidden: bool,
public: String,
}
#[derive(Template)]
#[template(path = "dashboard.html", blocks = ["status"])]
struct DashboardPage {
layout: Layout,
t: &'static Pages,
status: Status,
profile: Option<ProfileStatus>,
can_write: bool,
}
async fn dashboard(
State(app): State<AppState>,
request: HeaderMap,
Extension(lang): Extension<Lang>,
) -> Result<Response, AppError> {
let fragment = request.get("hx-request").is_some_and(|v| v == "true")
&& !request.contains_key("hx-history-restore-request");
let Some(access) = session(&app, &request) else {
if fragment {
let mut response = StatusCode::UNAUTHORIZED.into_response();
response
.headers_mut()
.insert("hx-refresh", HeaderValue::from_static("true"));
return Ok(response);
}
return Ok(sign_in_page(&app, StatusCode::UNAUTHORIZED, None, lang));
};
let library = app.owner_library().await?.db.read(store::stats).await?;
let owner = if fragment {
None
} else {
profile::owner_orcid(&app).await?
};
let profile = match &owner {
Some(orcid) if !fragment => Some(ProfileStatus {
url: format!("{}/{orcid}", app.cfg.public_url),
hidden: profile::hidden(&app).await?,
public: lang.works(
app.owner_library()
.await?
.db
.read(store::public_work_count)
.await?,
),
}),
_ => None,
};
let dashboard = DashboardPage {
layout: Layout::new(true, lang),
t: lang.pages(),
status: Status::new(&app, &library, lang),
profile,
can_write: access.write,
};
let rendered = if fragment {
dashboard.as_status().render()
} else {
dashboard.render()
};
let mut response = page(StatusCode::OK, &dashboard.layout, rendered);
let headers = response.headers_mut();
headers.extend(zotero::server_headers(&app.server_id));
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(header::VARY, HeaderValue::from_static("HX-Request"));
Ok(response)
}
fn format_count(n: i64, lang: Lang) -> String {
let out = lang.count(n.unsigned_abs() as usize);
if n < 0 { format!("-{out}") } else { out }
}
fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 || value >= 10.0 {
format!("{value:.0} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginView<'a> {
Pending {
token: &'a str,
error: Option<&'a str>,
signed_in: bool,
oidc: Option<&'a str>,
key_login: bool,
},
Approved,
Expired,
Missing,
Refused(&'a str),
}
impl<'a> LoginView<'a> {
fn title(&self, t: &'a Pages) -> &'a str {
match self {
LoginView::Pending { .. } => t.authorize_zotero,
LoginView::Approved => t.authorized,
LoginView::Expired => t.login_expired,
LoginView::Missing => t.no_login_request,
LoginView::Refused(_) => t.not_authorized,
}
}
fn message(&self, t: &'a Pages) -> &'a str {
match self {
LoginView::Pending {
signed_in: true, ..
} => t.pending_signed_in,
LoginView::Pending {
key_login: false, ..
} => t.pending_sign_in,
LoginView::Pending { .. } => t.pending_key,
LoginView::Approved => t.return_to_zotero,
LoginView::Expired => t.expired_message,
LoginView::Missing => t.missing_message,
LoginView::Refused(reason) => reason,
}
}
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginPage<'a> {
layout: Layout,
t: &'static Pages,
view: LoginView<'a>,
}
pub fn login_page(status: StatusCode, view: LoginView, lang: Lang) -> Response {
let mut layout = Layout::new(false, lang);
layout.lang_action = "/login";
match &view {
LoginView::Pending { token, .. } => {
layout.lang_fields.push(("session", token.to_string()));
}
LoginView::Approved => layout.lang_fields.push(("approved", "1".to_string())),
_ => {}
}
let login = LoginPage {
layout,
t: lang.pages(),
view,
};
let mut response = page(status, &login.layout, login.render());
let headers = response.headers_mut();
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("same-origin"),
);
response
}
struct FacetValue {
label: String,
count: String,
href: String,
picked: bool,
}
struct Facet {
key: &'static str,
heading: &'static str,
values: Vec<FacetValue>,
picked: bool,
}
struct Chip {
heading: &'static str,
label: String,
href: String,
}
struct PageLink {
label: String,
href: Option<String>,
current: bool,
}
#[derive(Template)]
#[template(path = "profile.html", blocks = ["works"])]
struct ProfilePage {
layout: Layout,
t: &'static Text,
lang: Lang,
languages: [Lang; 2],
lang_param: Option<&'static str>,
orcid: String,
person: PersonView,
works_count: String,
facets: Vec<Facet>,
chips: Vec<Chip>,
featured: Option<FacetValue>,
filter: store::WorkFilter,
q: String,
clear_href: String,
clear_search_href: String,
formats: &'static [profile::ExportFormat],
default_format: &'static str,
styles: &'static [(&'static str, &'static str)],
works: Vec<WorkView>,
total: String,
start: usize,
page_of: Option<String>,
pager: Vec<PageLink>,
previous_href: Option<String>,
next_href: Option<String>,
}
struct ProfileLinks<'a> {
orcid: &'a str,
lang: Option<&'static str>,
}
impl ProfileLinks<'_> {
fn href(&self, filter: &store::WorkFilter, page: usize) -> String {
let mut query: Vec<(&str, String)> = Vec::new();
query.extend(filter.q.iter().map(|q| ("q", q.clone())));
query.extend(filter.years.iter().map(|y| ("year", y.clone())));
query.extend(filter.types.iter().map(|t| ("type", t.clone())));
if filter.pdf_keys.is_some() {
query.push(("content", "pdf".to_string()));
}
let bare = query.is_empty() && page <= 1;
if filter.featured {
query.push(("featured", "1".to_string()));
} else if bare {
query.push(("featured", "0".to_string()));
}
query.extend(self.lang.map(|l| ("lang", l.to_string())));
if page > 1 {
query.push(("page", page.to_string()));
}
match serde_urlencoded::to_string(&query) {
Ok(query) if !query.is_empty() => format!("/{}?{query}", self.orcid),
_ => format!("/{}", self.orcid),
}
}
}
fn toggled(values: &[String], value: &str) -> Vec<String> {
if values.iter().any(|v| v == value) {
values.iter().filter(|v| *v != value).cloned().collect()
} else {
values.iter().cloned().chain([value.to_string()]).collect()
}
}
fn pager_pages(page: usize, pages: usize) -> Vec<Option<usize>> {
let mut wanted: Vec<usize> = [
1,
pages,
page.saturating_sub(2),
page.saturating_sub(1),
page,
page + 1,
page + 2,
]
.into_iter()
.filter(|n| (1..=pages).contains(n))
.collect();
wanted.sort_unstable();
wanted.dedup();
let mut out: Vec<Option<usize>> = Vec::new();
for n in wanted {
if let Some(Some(previous)) = out.last().copied() {
if n == previous + 2 {
out.push(Some(previous + 1));
} else if n > previous + 2 {
out.push(None);
}
}
out.push(Some(n));
}
out
}
async fn shown_profile(app: &AppState, id: &str) -> Result<String, AppError> {
let not_found = || AppError::NotFound("no such profile".to_string());
let orcid = profile::owner_orcid(app).await?.ok_or_else(not_found)?;
if id != orcid || profile::hidden(app).await? {
return Err(not_found());
}
Ok(orcid)
}
async fn work_pdfs(app: &AppState) -> Result<HashMap<String, String>, AppError> {
let sources = app
.owner_library()
.await?
.db
.read(store::public_work_pdf_sources)
.await?;
Ok(profile::work_pdfs(&sources, &app.cfg.public_url))
}
fn pdf_keys(pdfs: &HashMap<String, String>) -> Arc<Vec<String>> {
Arc::new(pdfs.keys().cloned().collect())
}
struct ProfileQuery {
params: Vec<(String, String)>,
filter: store::WorkFilter,
asked_for: Option<Lang>,
lang: Lang,
}
impl ProfileQuery {
fn new(query: Option<&str>, headers: &HeaderMap) -> Self {
let params: Vec<(String, String)> =
serde_urlencoded::from_str(query.unwrap_or("")).unwrap_or_default();
let param = |key: &str| {
params
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
};
let picked = |key: &str| -> Vec<String> {
params
.iter()
.filter(|(k, v)| k == key && !v.is_empty())
.map(|(_, v)| v.clone())
.take(profile::FACET_PICKS_MAX)
.collect()
};
let asked_for = param("lang").and_then(Lang::parse);
let lang = request_lang(asked_for, headers);
let filter = store::WorkFilter {
years: picked("year"),
types: picked("type"),
q: param("q")
.map(|q| {
q.trim()
.chars()
.take(profile::SEARCH_MAX)
.collect::<String>()
})
.filter(|q| !q.is_empty()),
featured: param("featured") == Some("1"),
pdf_keys: None,
key: None,
};
ProfileQuery {
filter,
asked_for,
lang,
params,
}
}
fn is_default_view(&self) -> bool {
self.params.iter().all(|(key, _)| key == "lang")
}
fn param(&self, key: &str) -> Option<&str> {
self.params
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
}
async fn profile_page(
State(app): State<AppState>,
Path(id): Path<String>,
RawQuery(query): RawQuery,
headers: HeaderMap,
) -> Result<Response, AppError> {
let orcid = shown_profile(&app, &id).await?;
let person = profile::person(&app, &orcid).await?;
let asked = ProfileQuery::new(query.as_deref(), &headers);
let (lang, t) = (asked.lang, asked.lang.text());
let pdfs = work_pdfs(&app).await?;
let counts = app
.owner_library()
.await?
.db
.read(|c| store::public_work_facets(c, profile::FACET_VALUES))
.await?;
let mut filter = asked.filter.clone();
if asked.param("content") == Some("pdf") {
filter.pdf_keys = Some(pdf_keys(&pdfs));
}
if asked.is_default_view() && counts.featured > 0 {
filter.featured = true;
}
let page_number = asked
.param("page")
.and_then(|p| p.parse::<u32>().ok())
.filter(|p| *p >= 1 && !filter.featured)
.unwrap_or(1) as usize;
let start = (page_number - 1) * profile::PAGE_SIZE;
let limit = if filter.featured {
profile::FEATURED_MAX
} else {
profile::PAGE_SIZE
};
let wanted = filter.clone();
let (total, works) = app
.owner_library()
.await?
.db
.read(move |c| store::public_works(c, &wanted, start, limit))
.await?;
let total = if filter.featured {
total.min(profile::FEATURED_MAX)
} else {
total
};
let works_count = app
.owner_library()
.await?
.db
.read(store::public_work_count)
.await?;
let links = ProfileLinks {
orcid: &orcid,
lang: asked.asked_for.as_ref().map(Lang::code),
};
let with_year = |year: &str| {
let filter = store::WorkFilter {
years: toggled(&filter.years, year),
featured: filter.featured && filter.years.iter().any(|y| y == year),
..filter.clone()
};
links.href(&filter, 1)
};
let with_type = |item_type: &str| {
let filter = store::WorkFilter {
types: toggled(&filter.types, item_type),
featured: filter.featured && filter.types.iter().any(|t| t == item_type),
..filter.clone()
};
links.href(&filter, 1)
};
let without_search = links.href(
&store::WorkFilter {
q: None,
..filter.clone()
},
1,
);
let toggled_featured = links.href(
&store::WorkFilter {
featured: !filter.featured,
..filter.clone()
},
1,
);
let toggled_pdf = links.href(
&store::WorkFilter {
pdf_keys: match filter.pdf_keys {
Some(_) => None,
None => Some(pdf_keys(&pdfs)),
},
featured: filter.featured && filter.pdf_keys.is_some(),
..filter.clone()
},
1,
);
let mut facets = vec![
Facet {
key: "year",
heading: t.facet_year,
picked: !filter.years.is_empty(),
values: counts
.years
.iter()
.map(|(year, count)| FacetValue {
label: year.clone(),
count: lang.count(*count),
href: with_year(year),
picked: filter.years.contains(year),
})
.collect(),
},
Facet {
key: "type",
heading: t.facet_type,
picked: !filter.types.is_empty(),
values: counts
.types
.iter()
.map(|(item_type, count)| FacetValue {
label: lang.item_type(item_type),
count: lang.count(*count),
href: with_type(item_type),
picked: filter.types.contains(item_type),
})
.collect(),
},
Facet {
key: "content",
heading: t.content,
picked: filter.pdf_keys.is_some(),
values: (!pdfs.is_empty() || filter.pdf_keys.is_some())
.then(|| FacetValue {
label: "PDF".to_string(),
count: lang.count(pdfs.len()),
href: toggled_pdf.clone(),
picked: filter.pdf_keys.is_some(),
})
.into_iter()
.collect(),
},
];
facets.retain(|facet| !facet.values.is_empty());
let mut chips: Vec<Chip> = Vec::new();
if let Some(q) = &filter.q {
chips.push(Chip {
heading: t.search,
label: q.clone(),
href: without_search.clone(),
});
}
if filter.featured {
chips.push(Chip {
heading: "",
label: t.featured_only.to_string(),
href: toggled_featured.clone(),
});
}
chips.extend(filter.years.iter().map(|year| Chip {
heading: t.facet_year,
label: year.clone(),
href: with_year(year),
}));
chips.extend(filter.types.iter().map(|item_type| Chip {
heading: t.facet_type,
label: lang.item_type(item_type),
href: with_type(item_type),
}));
if filter.pdf_keys.is_some() {
chips.push(Chip {
heading: t.content,
label: "PDF".to_string(),
href: toggled_pdf.clone(),
});
}
let page_count = total.div_ceil(profile::PAGE_SIZE);
let pager = pager_pages(page_number, page_count)
.into_iter()
.map(|n| match n {
Some(n) => PageLink {
label: lang.count(n),
href: Some(links.href(&filter, n)),
current: n == page_number,
},
None => PageLink {
label: "…".to_string(),
href: None,
current: false,
},
})
.collect();
let fragment = headers.get("hx-request").is_some_and(|v| v == "true")
&& !headers.contains_key("hx-history-restore-request");
let profile = ProfilePage {
layout: Layout {
wide: true,
..Layout::new(signed_in(&app, &headers), lang)
},
t,
lang,
languages: Lang::ALL,
lang_param: links.lang,
person: PersonView::new(&orcid, &person, lang),
works_count: lang.works(works_count),
facets,
chips,
featured: (counts.featured > 0 || filter.featured).then(|| FacetValue {
label: t.featured_only.to_string(),
count: lang.count(counts.featured.min(profile::FEATURED_MAX)),
href: toggled_featured.clone(),
picked: filter.featured,
}),
q: filter.q.clone().unwrap_or_default(),
clear_href: links.href(&store::WorkFilter::default(), 1),
clear_search_href: without_search.clone(),
formats: &profile::EXPORT_FORMATS,
default_format: profile::DEFAULT_EXPORT_FORMAT,
styles: &profile::CITATION_STYLES,
works: works
.iter()
.map(|w| WorkView {
pdf: w
.get("key")
.and_then(|k| k.as_str())
.and_then(|k| pdfs.get(k))
.cloned(),
..WorkView::new(w, lang)
})
.collect(),
total: lang.works(total),
start,
page_of: (page_count > 1).then(|| lang.page_of(page_number, page_count)),
pager,
previous_href: (page_number > 1 && page_count > 1)
.then(|| links.href(&filter, page_number - 1)),
next_href: (page_number < page_count).then(|| links.href(&filter, page_number + 1)),
filter,
orcid: orcid.clone(),
};
let rendered = if fragment {
profile.as_works().render()
} else {
profile.render()
};
let mut response = page(StatusCode::OK, &profile.layout, rendered);
let headers = response.headers_mut();
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
headers.insert(
header::VARY,
HeaderValue::from_static("HX-Request, Accept-Language"),
);
Ok(response)
}
async fn profile_export(
State(app): State<AppState>,
Path(id): Path<String>,
RawQuery(query): RawQuery,
headers: HeaderMap,
) -> Result<Response, AppError> {
let orcid = shown_profile(&app, &id).await?;
let asked = ProfileQuery::new(query.as_deref(), &headers);
let format = asked
.param("format")
.or(Some(profile::DEFAULT_EXPORT_FORMAT))
.and_then(profile::ExportFormat::find)
.ok_or_else(|| AppError::BadRequest("format: not one on offer".to_string()))?;
let style = asked
.param("style")
.unwrap_or(profile::CITATION_STYLES[0].0);
let style = profile::CITATION_STYLES
.iter()
.find(|(value, _)| *value == style)
.map(|(value, _)| *value)
.ok_or_else(|| AppError::BadRequest("style: not one on offer".to_string()))?;
let mut filter = asked.filter.clone();
if asked.param("content") == Some("pdf") {
filter.pdf_keys = Some(pdf_keys(&work_pdfs(&app).await?));
}
filter.key = asked.param("key").map(String::from);
let one_work = filter.key.is_some();
let limit = if filter.featured {
profile::FEATURED_MAX
} else {
profile::EXPORT_MAX
};
let (_, works) = app
.owner_library()
.await?
.db
.read(move |c| store::public_works(c, &filter, 0, limit))
.await?;
if one_work && works.is_empty() {
return Err(AppError::NotFound("no such work".to_string()));
}
let lang = asked.lang;
let bytes = tokio::task::spawn_blocking(move || profile::export(&works, format, style, lang))
.await
.map_err(|e| AppError::Internal(format!("blocking task: {e}")))?
.map_err(|e| AppError::Internal(format!("exporting works: {e}")))?;
let disposition = format!("attachment; filename=\"{}\"", format.filename(&orcid));
let mut response = bytes.into_response();
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(format.content_type()),
);
if let Ok(value) = HeaderValue::from_str(&disposition) {
headers.insert(header::CONTENT_DISPOSITION, value);
}
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
Ok(response)
}
async fn profile_visibility(
State(app): State<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
if auth::foreign_origin(&headers, &app.cfg.public_url) {
return Ok(StatusCode::FORBIDDEN.into_response());
}
let Some(access) = session(&app, &headers) else {
return Ok(StatusCode::UNAUTHORIZED.into_response());
};
if !access.write {
return Ok(StatusCode::FORBIDDEN.into_response());
}
let hidden = match form.get("hidden").map(String::as_str) {
Some("true") => true,
Some("false") => false,
_ => return Err(AppError::BadRequest("hidden: true or false".to_string())),
};
profile::set_hidden(&app, hidden).await?;
Ok(Redirect::to("/").into_response())
}
async fn asset(Path(name): Path<String>) -> Response {
let Some((_, bytes)) = assets::UI_ASSETS.iter().find(|(n, _)| *n == name) else {
return StatusCode::NOT_FOUND.into_response();
};
let content_type = match name.rsplit('.').next() {
Some("js") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("woff2") => "font/woff2",
Some("png") => "image/png",
_ => "application/octet-stream",
};
(
[
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
*bytes,
)
.into_response()
}
async fn status(State(app): State<AppState>, headers: HeaderMap) -> Result<Response, AppError> {
if !authorized(&app, &headers) {
return Ok((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "an API key or a signed-in session is required" })),
)
.into_response());
}
let library = app.owner_library().await?.db.read(store::stats).await?;
Ok(Json(json!({
"name": "defter",
"version": env!("CARGO_PKG_VERSION"),
"commonmetaVersion": commonmeta::VERSION,
"serverId": app.server_id,
"publicUrl": app.cfg.public_url,
"apiUrl": format!("{}/api", app.cfg.public_url),
"userId": app.cfg.user_id,
"username": app.cfg.username,
"backup": match app.backup.get() {
None => json!({"enabled": false}),
Some(backup) => {
let last = backup.last();
json!({
"enabled": true,
"lastAt": last.as_ref().map(|r| crate::backup::utc_text(r.at)),
"manifest": last.as_ref().and_then(|r| r.manifest.clone()),
"error": last.as_ref().and_then(|r| r.error.clone()),
"warning": last.and_then(|r| r.warning),
})
}
},
"storage": {
"location": app.storage.description(),
"signedDownloads": app.storage.signs_downloads(),
"maxUpload": app.cfg.max_upload,
},
"library": library,
}))
.into_response())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_assets_are_named_by_content() {
assert!(assets::STYLESHEET.starts_with("/assets/app-"));
assert!(assets::STYLESHEET.ends_with(".css"));
assert!(assets::SCRIPT.starts_with("/assets/app-"));
assert!(assets::SCRIPT.ends_with(".js"));
let names: Vec<&str> = assets::UI_ASSETS.iter().map(|(n, _)| *n).collect();
assert_eq!(
names,
[&assets::STYLESHEET[8..], &assets::SCRIPT[8..]],
"the URLs name the embedded files"
);
let (_, script) = assets::UI_ASSETS[1];
assert!(script.starts_with(b"var htmx="));
assert!(String::from_utf8_lossy(script).contains("data-theme-toggle"));
}
#[test]
fn the_session_token_is_escaped_in_the_form() {
let html = LoginPage {
layout: Layout::new(false, Lang::En),
t: Lang::En.pages(),
view: LoginView::Pending {
token: "\"><script>alert(1)</script>",
error: None,
signed_in: false,
oidc: None,
key_login: true,
},
}
.render()
.unwrap();
assert!(
html.contains(r#"value=""><script>alert(1)"#),
"{html}"
);
assert!(!html.contains("<script>alert"));
}
#[test]
fn formats_counts_and_bytes() {
assert_eq!(format_count(0, Lang::En), "0");
assert_eq!(format_count(999, Lang::En), "999");
assert_eq!(format_count(1234567, Lang::En), "1,234,567");
assert_eq!(format_count(-1000, Lang::En), "-1,000");
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(1536), "1.5 KB");
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
assert_eq!(format_bytes(20 * 1024 * 1024), "20 MB");
}
}