use std::collections::HashMap;
use askama::Template;
use axum::{
Form, Json, Router,
extract::{Path, RawQuery, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
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, Text};
use crate::profile::{self, PersonView, WorkView};
use crate::{auth, 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/profile", post(profile_visibility))
.route("/{orcid}", get(profile_page))
}
fn session(app: &AppState, headers: &HeaderMap) -> Option<auth::Access> {
auth::cookie(headers, auth::UI_SESSION_COOKIE).and_then(|t| app.ui_sessions.get(t))
}
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,
error: Option<&'a str>,
}
fn sign_in_page(app: &AppState, status: StatusCode, error: Option<&str>) -> Response {
let signin = SignInPage {
layout: Layout::new(false),
error,
};
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,
Form(form): Form<HashMap<String, String>>,
) -> Response {
if auth::foreign_origin(&headers, &app.cfg.public_url) {
return sign_in_page(
&app,
StatusCode::FORBIDDEN,
Some("The sign-in was sent from another site."),
);
}
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("That is not an API key of this library."),
);
};
let (token, _) = app.ui_sessions.insert(access);
let mut response = Redirect::to("/").into_response();
response
.headers_mut()
.insert(header::SET_COOKIE, session_cookie(&app, Some(&token)));
response
}
async fn sign_out(State(app): State<AppState>, headers: HeaderMap) -> Response {
if auth::foreign_origin(&headers, &app.cfg.public_url) {
return StatusCode::FORBIDDEN.into_response();
}
if let Some(token) = auth::cookie(&headers, auth::UI_SESSION_COOKIE) {
app.ui_sessions.remove(token);
}
let mut response = Redirect::to("/").into_response();
response
.headers_mut()
.insert(header::SET_COOKIE, session_cookie(&app, None));
response
}
struct Layout {
stylesheet: &'static str,
script: &'static str,
signed_in: bool,
wide: bool,
text: &'static i18n::LayoutText,
nonce: String,
}
impl Layout {
fn new(signed_in: bool) -> Self {
Layout {
stylesheet: assets::STYLESHEET,
script: assets::SCRIPT,
signed_in,
wide: false,
text: &i18n::LAYOUT_EN,
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,
api_url: String,
}
impl Status {
fn new(app: &AppState, library: &store::LibraryStats) -> Self {
let stat = |label, value| Stat { label, value };
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("Items", format_count(library.items)),
stat("Attachments", format_count(library.attachments)),
stat("Notes", format_count(library.notes)),
stat("Collections", format_count(library.collections)),
stat("Tags", format_count(library.tags)),
stat("In trash", format_count(library.trashed)),
stat("Files", format_count(library.files)),
stat("Stored", format_bytes(library.file_bytes as u64)),
],
storage_location: app.storage.description().to_string(),
signed_downloads: app.storage.signs_downloads(),
max_upload: format_bytes(app.cfg.max_upload),
api_url: format!("{}/api", app.cfg.public_url),
}
}
}
struct ProfileStatus {
url: String,
hidden: bool,
public: usize,
}
#[derive(Template)]
#[template(path = "dashboard.html", blocks = ["status"])]
struct DashboardPage {
layout: Layout,
status: Status,
profile: Option<ProfileStatus>,
can_write: bool,
}
async fn dashboard(State(app): State<AppState>, request: HeaderMap) -> 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));
};
let library = app.db.read(store::stats).await?;
let profile = match &app.cfg.orcid {
Some(orcid) if !fragment => Some(ProfileStatus {
url: format!("{}/{orcid}", app.cfg.public_url),
hidden: profile::hidden(&app).await?,
public: app.db.read(store::public_work_count).await?,
}),
_ => None,
};
let dashboard = DashboardPage {
layout: Layout::new(true),
status: Status::new(&app, &library),
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) -> String {
let digits = n.unsigned_abs().to_string();
let mut out = String::new();
for (i, digit) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(digit);
}
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>,
},
Approved,
Expired,
Missing,
Refused(&'a str),
}
impl<'a> LoginView<'a> {
fn title(&self) -> &'static str {
match self {
LoginView::Pending { .. } => "Authorize Zotero?",
LoginView::Approved => "Authorized",
LoginView::Expired => "Login expired",
LoginView::Missing => "No login request",
LoginView::Refused(_) => "Not authorized",
}
}
fn message(&self) -> &'a str {
match self {
LoginView::Pending { .. } => {
"A Zotero client asked to sync with this library. Approve with an API key \
that may write to it, and only if you just started the login yourself."
}
LoginView::Approved => "You can close this page and return to Zotero.",
LoginView::Expired => {
"This login request is unknown or has expired. Start the login in Zotero again."
}
LoginView::Missing => "Open this page from Zotero’s Settings → Sync → Log In.",
LoginView::Refused(reason) => reason,
}
}
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginPage<'a> {
layout: Layout,
view: LoginView<'a>,
}
pub fn login_page(status: StatusCode, view: LoginView) -> Response {
let login = LoginPage {
layout: Layout::new(false),
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; 3],
lang_param: Option<&'static str>,
orcid: String,
person: PersonView,
works_count: String,
facets: Vec<Facet>,
chips: Vec<Chip>,
filter: store::WorkFilter,
q: String,
clear_href: String,
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())));
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 profile_page(
State(app): State<AppState>,
Path(id): Path<String>,
RawQuery(query): RawQuery,
headers: HeaderMap,
) -> Result<Response, AppError> {
let not_found = || AppError::NotFound("no such profile".to_string());
let orcid = app.cfg.orcid.clone().ok_or_else(not_found)?;
if id != orcid || profile::hidden(&app).await? {
return Err(not_found());
}
let person = profile::person(&app, &orcid).await?;
let params: Vec<(String, String)> =
serde_urlencoded::from_str(query.as_deref().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 = asked_for.unwrap_or_else(|| {
let accept = headers.get(header::ACCEPT_LANGUAGE);
Lang::from_accept_language(accept.and_then(|v| v.to_str().ok()).unwrap_or(""))
});
let t = lang.text();
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()),
};
let page_number = param("page")
.and_then(|p| p.parse::<u32>().ok())
.filter(|p| *p >= 1)
.unwrap_or(1) as usize;
let start = (page_number - 1) * profile::PAGE_SIZE;
let wanted = filter.clone();
let (total, works) = app
.db
.read(move |c| store::public_works(c, &wanted, start, profile::PAGE_SIZE))
.await?;
let works_count = app.db.read(store::public_work_count).await?;
let counts = app
.db
.read(|c| store::public_work_facets(c, profile::FACET_VALUES))
.await?;
let links = ProfileLinks {
orcid: &orcid,
lang: asked_for.as_ref().map(Lang::code),
};
let with_year = |year: &str| {
let filter = store::WorkFilter {
years: toggled(&filter.years, year),
..filter.clone()
};
links.href(&filter, 1)
};
let with_type = |item_type: &str| {
let filter = store::WorkFilter {
types: toggled(&filter.types, item_type),
..filter.clone()
};
links.href(&filter, 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(),
},
];
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: links.href(
&store::WorkFilter {
q: None,
..filter.clone()
},
1,
),
});
}
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),
}));
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 profile = ProfilePage {
layout: Layout {
wide: true,
text: lang.layout(),
..Layout::new(signed_in(&app, &headers))
},
t,
lang,
languages: Lang::ALL,
lang_param: links.lang,
person: PersonView::new(&orcid, &person, lang),
works_count: lang.works(works_count),
facets,
chips,
q: filter.q.clone().unwrap_or_default(),
clear_href: links.href(&store::WorkFilter::default(), 1),
works: works.iter().map(|w| 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 fragment = headers.get("hx-request").is_some_and(|v| v == "true")
&& !headers.contains_key("hx-history-restore-request");
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_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.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,
"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),
view: LoginView::Pending {
token: "\"><script>alert(1)</script>",
error: None,
},
}
.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), "0");
assert_eq!(format_count(999), "999");
assert_eq!(format_count(1234567), "1,234,567");
assert_eq!(format_count(-1000), "-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");
}
}