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::control::{
Group, GroupRefusal, Member, Role, RunStatus, SignIn, SyncRun, SyncSetting, User,
};
use crate::error::AppError;
use crate::i18n::{self, Lang, Pages, Text};
use crate::profile::{self, PersonView, WorkView};
use crate::registry::Library;
use crate::{auth, oidc, store, zotero, zotero_org};
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/account", get(account_page).post(account_connect))
.route("/ui/profile", post(profile_visibility))
.route("/ui/sync", post(sync_settings))
.route("/ui/sync/run", post(sync_run))
.route("/ui/sync/log.json", get(sync_log))
.route("/ui/keys/revoke", post(revoke_key))
.route("/ui/groups", post(group_create))
.route("/ui/groups/{id}", get(group_page))
.route("/ui/groups/{id}/settings", post(group_settings))
.route("/ui/groups/{id}/members", post(group_member))
.route("/ui/groups/{id}/remove", post(group_remove))
.route("/ui/groups/{id}/delete", post(group_delete))
.route("/ui/admin", get(admin_page))
.route("/ui/admin/invite", post(admin_invite))
.route("/ui/admin/uninvite", post(admin_uninvite))
.route("/ui/admin/user", post(admin_user))
.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::UiSession> {
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()
}
struct Viewer {
user: User,
access: auth::Access,
admin: bool,
}
async fn viewer(app: &AppState, headers: &HeaderMap) -> Result<Option<Viewer>, AppError> {
let Some(session) = session(app, headers) else {
return Ok(None);
};
let user = app.control.user(session.user_id).await?;
Ok(user.filter(|u| !u.disabled).map(|user| Viewer {
admin: app.is_admin(&user),
user,
access: session.access,
}))
}
async fn authorized(app: &AppState, headers: &HeaderMap) -> Result<Option<User>, AppError> {
if let Some(viewer) = viewer(app, headers).await? {
return Ok(Some(viewer.user));
}
let Some(key) = auth::request_key(headers) else {
return Ok(None);
};
match auth::principal(app, key).await? {
Some(principal) => app.control.user(principal.user_id).await,
None => Ok(None),
}
}
async fn may_change(
app: &AppState,
headers: &HeaderMap,
admin: bool,
) -> Result<Result<Viewer, Response>, AppError> {
if auth::foreign_origin(headers, &app.cfg.public_url) {
return Ok(Err(StatusCode::FORBIDDEN.into_response()));
}
let Some(viewer) = viewer(app, headers).await? else {
return Ok(Err(StatusCode::UNAUTHORIZED.into_response()));
};
if !viewer.access.write || (admin && !viewer.admin) {
return Ok(Err(StatusCode::FORBIDDEN.into_response()));
}
Ok(Ok(viewer))
}
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,
ask_admin: Option<Option<&'a str>>,
}
fn sign_in_page(app: &AppState, status: StatusCode, error: Option<&str>, lang: Lang) -> Response {
refused_page(app, status, error, None, lang)
}
fn refused_page<'a>(
app: &'a AppState,
status: StatusCode,
error: Option<&'a str>,
ask_admin: Option<Option<&'a 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,
ask_admin,
};
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 {
user_id: app.cfg.user_id,
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,
);
};
let admin = app.is_admin_orcid(Some(&orcid));
let grant = signed_in.grant;
let user = match app
.control
.sign_in(&orcid, admin, grant, app.cfg.user_id)
.await
{
Ok(SignIn::User(user)) => user,
Ok(SignIn::NoAccount) => {
let (token, _) = app.pending_accounts.insert(PendingAccount {
orcid,
grant,
id_token: signed_in.id_token,
login_session: pending.login_session,
});
let mut response = Redirect::to("/ui/account").into_response();
let headers = response.headers_mut();
headers.append(header::SET_COOKIE, account_cookie(&app, Some(&token)));
headers.append(header::SET_COOKIE, oidc_state_cookie(&app, None));
return response;
}
Ok(refused) => {
if let SignIn::NoAccess { disabled: Some(id) } = refused {
app.ui_sessions.retain(|session| session.user_id != id);
tracing::warn!(user = id, "disabled: the provider no longer grants access");
}
tracing::warn!(detail = %format!("{orcid}: {refused:?}"), "OpenID Connect sign-in refused");
let mut response = refused_page(
&app,
StatusCode::FORBIDDEN,
Some(t.not_allowed),
Some(app.cfg.admin_email.as_deref()),
lang,
);
response
.headers_mut()
.insert(header::SET_COOKIE, oidc_state_cookie(&app, None));
return response;
}
Err(error) => return error.into_response(),
};
let mut response = start_session(&app, &user, signed_in.id_token, pending.login_session);
response
.headers_mut()
.append(header::SET_COOKIE, oidc_state_cookie(&app, None));
response
}
fn start_session(
app: &AppState,
user: &User,
id_token: String,
login_session: Option<String>,
) -> Response {
if user.display_name.is_none() {
name_from_orcid(app, user);
}
let (token, _) = app.ui_sessions.insert(auth::UiSession {
user_id: user.id,
access: auth::Access { write: true },
id_token: Some(id_token),
});
let back = match login_session {
Some(login) => format!("/login?session={login}"),
None => "/".to_string(),
};
let mut response = Redirect::to(&back).into_response();
response
.headers_mut()
.append(header::SET_COOKIE, session_cookie(app, Some(&token)));
response
}
pub const ACCOUNT_TTL: std::time::Duration = std::time::Duration::from_secs(900);
const ACCOUNT_COOKIE: &str = "defter_account";
#[derive(Debug, Clone)]
pub struct PendingAccount {
pub orcid: String,
pub grant: Option<crate::control::Grant>,
pub id_token: String,
pub login_session: Option<String>,
}
fn account_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, ACCOUNT_TTL.as_secs()),
None => ("", 0),
};
HeaderValue::from_str(&format!(
"{ACCOUNT_COOKIE}={value}; Path=/ui/account; HttpOnly; SameSite=Lax; \
Max-Age={max_age}{secure}"
))
.expect("cookie of hex and fixed attributes")
}
#[derive(Template)]
#[template(path = "account.html")]
struct AccountPage<'a> {
layout: Layout,
t: &'static Pages,
error: Option<&'a str>,
}
fn account_page_with(status: StatusCode, error: Option<&str>, lang: Lang) -> Response {
let account = AccountPage {
layout: Layout::new(false, lang),
t: lang.pages(),
error,
};
let mut response = page(status, &account.layout, account.render());
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
fn account_expired(app: &AppState, lang: Lang) -> Response {
let mut response = sign_in_page(
app,
StatusCode::FORBIDDEN,
Some(lang.pages().account_expired),
lang,
);
response
.headers_mut()
.insert(header::SET_COOKIE, account_cookie(app, None));
response
}
async fn account_page(
State(app): State<AppState>,
headers: HeaderMap,
Extension(lang): Extension<Lang>,
) -> Response {
let pending = auth::cookie(&headers, ACCOUNT_COOKIE).and_then(|t| app.pending_accounts.get(t));
if pending.is_none() {
return account_expired(&app, lang);
}
account_page_with(StatusCode::OK, None, lang)
}
async fn account_connect(
State(app): State<AppState>,
headers: HeaderMap,
Extension(lang): Extension<Lang>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let t = lang.pages();
if auth::foreign_origin(&headers, &app.cfg.public_url) {
return account_page_with(StatusCode::FORBIDDEN, Some(t.from_another_site), lang);
}
let Some(token) = auth::cookie(&headers, ACCOUNT_COOKIE) else {
return account_expired(&app, lang);
};
let Some(pending) = app.pending_accounts.get(token) else {
return account_expired(&app, lang);
};
let key = form.get("key").map(|k| k.trim()).unwrap_or_default();
let account = match zotero_org::account(&app.http, &app.cfg.zotero_api_url, key).await {
Ok(account) => account,
Err(zotero_org::Refusal::Rejected) => {
tracing::warn!("zotero.org did not accept a key");
tokio::time::sleep(auth::WRONG_KEY_DELAY).await;
return account_page_with(StatusCode::FORBIDDEN, Some(t.zotero_key_rejected), lang);
}
Err(zotero_org::Refusal::Unreachable(detail)) => {
tracing::warn!(%detail, "asking zotero.org whose a key is");
return account_page_with(StatusCode::BAD_GATEWAY, Some(t.zotero_unreachable), lang);
}
};
let admin = app.is_admin_orcid(Some(&pending.orcid));
let joined = app
.control
.join(
&pending.orcid,
admin,
pending.grant,
account.user_id,
&account.username,
app.cfg.user_id,
)
.await;
let user = match joined {
Ok(SignIn::User(user)) => user,
Ok(SignIn::IdTaken) => {
tracing::warn!(user = account.user_id, "zotero.org user ID taken");
return account_page_with(StatusCode::CONFLICT, Some(t.zotero_id_taken), lang);
}
Ok(refused) => {
app.pending_accounts.remove(token);
tracing::warn!(?refused, "joining refused");
let mut response = sign_in_page(&app, StatusCode::FORBIDDEN, Some(t.not_allowed), lang);
response
.headers_mut()
.insert(header::SET_COOKIE, account_cookie(&app, None));
return response;
}
Err(error) => return error.into_response(),
};
app.pending_accounts.remove(token);
tracing::info!(user = user.id, "joined");
let mut response = start_session(&app, &user, pending.id_token, pending.login_session);
response
.headers_mut()
.append(header::SET_COOKIE, account_cookie(&app, None));
response
}
fn name_from_orcid(app: &AppState, user: &User) {
#[cfg(not(test))]
if let Some(orcid) = user.orcid.clone() {
let (app, id) = (app.clone(), user.id);
tokio::spawn(async move {
let Ok(person) = profile::person(&app, &orcid).await else {
return;
};
if let Some(name) = profile::person_name(&person)
&& let Err(error) = app.control.set_display_name(id, name).await
{
tracing::warn!(%error, user = id, "saving the name from ORCID");
}
});
}
#[cfg(test)]
let _ = (app, user);
}
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,
admin: bool,
usage: String,
over_quota: bool,
defter_version: &'static str,
commonmeta_version: &'static str,
stats: Vec<Stat>,
signed_downloads: bool,
max_upload: String,
backup: String,
backup_failed: bool,
server_url: String,
plugin_url: &'static str,
}
impl Status {
fn new(
app: &AppState,
user: &User,
admin: bool,
library: &store::LibraryStats,
owned_groups: i64,
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));
let used = library.file_bytes.max(0) as u64 + owned_groups.max(0) as u64;
let quota = app.quota(user);
Status {
version: format_count(library.version),
user_id: user.id,
admin,
usage: match quota {
Some(quota) => format!("{} {} {}", format_bytes(used), t.of, format_bytes(quota)),
None => format!("{}, {}", format_bytes(used), t.no_limit),
},
over_quota: quota.is_some_and(|q| used >= q),
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)),
],
signed_downloads: app.storage.signs_downloads(),
backup: backup_status(app, t).0,
backup_failed: backup_status(app, t).1,
max_upload: format_bytes(app.cfg.max_upload),
server_url: app.cfg.public_url.clone(),
plugin_url: PLUGIN_URL,
}
}
}
const PLUGIN_URL: &str = "https://codeberg.org/front-matter/defter/releases/latest";
fn backup_status(app: &AppState, t: &Pages) -> (String, bool) {
let Some(backup) = app.backup.get() else {
return (t.backup_off.to_string(), false);
};
let Some(report) = backup.last() else {
return (t.backup_never.to_string(), false);
};
let text = match (&report.error, &report.warning) {
(Some(error), _) => format!("{} {error}", t.backup_failed),
(None, warning) => {
let last = format!("{} {}", t.backup_last, crate::backup::utc_text(report.at));
match warning {
Some(warning) => format!("{last}. {} {warning}", t.backup_warning),
None => last,
}
}
};
(text, report.error.is_some() || report.warning.is_some())
}
struct ProfileStatus {
url: String,
hidden: bool,
public: String,
}
struct GroupLink {
id: u64,
name: String,
role: &'static str,
}
struct DeviceView {
id: i64,
name: String,
prefix: String,
added: String,
last_used: Option<String>,
}
struct SyncResultView {
key: String,
doi: String,
status: String,
detail: String,
}
struct RunView {
label: &'static str,
at: String,
status: &'static str,
failed: bool,
counts: String,
error: Option<String>,
results: Vec<SyncResultView>,
more: i64,
}
impl RunView {
fn new(run: &SyncRun, lang: Lang) -> Self {
let t = lang.pages();
let summary: commonmeta::zotero::SyncSummary =
serde_json::from_str(&run.summary).unwrap_or_default();
let results: Vec<commonmeta::zotero::SyncResult> = run
.results
.as_deref()
.and_then(|r| serde_json::from_str(r).ok())
.unwrap_or_default();
RunView {
label: if run.dry_run {
t.sync_last_preview
} else {
t.sync_last
},
at: crate::backup::utc_text(run.finished_at.max(0) as u64),
status: sync_status_word(run.status, t),
failed: matches!(run.status, RunStatus::Failed | RunStatus::Partial),
counts: lang.sync_counts(&summary, run.dry_run),
error: run
.error
.clone()
.filter(|_| run.status == RunStatus::Failed),
more: (run.results_total - results.len().min(RESULTS_SHOWN) as i64).max(0),
results: results
.into_iter()
.take(RESULTS_SHOWN)
.map(|r| SyncResultView {
detail: [r.fields.join(", "), replaced(&r.changes), r.pdf, r.message]
.into_iter()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" · "),
key: r.key,
doi: r.doi,
status: r.status,
})
.collect(),
}
}
}
const RESULTS_SHOWN: usize = 100;
fn replaced(changes: &serde_json::Map<String, serde_json::Value>) -> String {
let text = |v: &serde_json::Value| match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
changes
.iter()
.filter(|(_, c)| {
!c["from"].is_null() && c["from"] != "" && c["from"] != serde_json::json!([])
})
.map(|(field, c)| format!("{field}: {} → {}", text(&c["from"]), text(&c["to"])))
.collect::<Vec<_>>()
.join("; ")
}
fn sync_status_word(status: RunStatus, t: &Pages) -> &'static str {
match status {
RunStatus::Ok => t.sync_ok,
RunStatus::Partial => t.sync_partial,
RunStatus::Failed => t.sync_failed,
RunStatus::Aborted => t.sync_aborted,
}
}
struct SyncView {
setting: SyncSetting,
has_orcid: bool,
running: bool,
busy: bool,
last: Option<RunView>,
}
#[derive(Template)]
#[template(path = "dashboard.html", blocks = ["status"])]
struct DashboardPage {
layout: Layout,
t: &'static Pages,
name: String,
status: Status,
profile: Option<ProfileStatus>,
groups: Vec<GroupLink>,
devices: Vec<DeviceView>,
sync: Option<SyncView>,
can_write: bool,
}
async fn dashboard(
State(app): State<AppState>,
request: HeaderMap,
Extension(lang): Extension<Lang>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Response, AppError> {
let fragment = request.get("hx-request").is_some_and(|v| v == "true")
&& !request.contains_key("hx-history-restore-request");
let Some(viewer) = viewer(&app, &request).await? 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.user_library(&viewer.user).await?;
let stats = library.db.read(store::stats).await?;
let profile = match &viewer.user.orcid {
Some(orcid) if !fragment => Some(ProfileStatus {
url: format!("{}/{orcid}", app.cfg.public_url),
hidden: viewer.user.profile_hidden,
public: lang.works(library.db.read(store::public_work_count).await?),
}),
_ => None,
};
let devices = if fragment {
Vec::new()
} else {
app.control
.keys(viewer.user.id)
.await?
.into_iter()
.map(|key| DeviceView {
id: key.id,
name: key.name,
prefix: key.prefix,
added: crate::backup::date(key.created_at.max(0) as u64),
last_used: key
.last_used_at
.map(|at| crate::backup::date(at.max(0) as u64)),
})
.collect()
};
let groups = if fragment {
Vec::new()
} else {
app.control
.groups_of(viewer.user.id)
.await?
.into_iter()
.map(|(group, role)| GroupLink {
id: group.id,
name: group.name,
role: role_name(role, lang.pages()),
})
.collect()
};
let sync = match &app.cfg.sync {
Some(_) if !fragment => Some(SyncView {
setting: app.control.sync_setting(viewer.user.id).await?,
has_orcid: viewer.user.orcid.is_some(),
running: app.sync.is_running(viewer.user.id),
busy: params.get("sync").is_some_and(|s| s == "busy"),
last: app
.control
.last_run(viewer.user.id)
.await?
.map(|run| RunView::new(&run, lang)),
}),
_ => None,
};
let dashboard = DashboardPage {
layout: Layout::new(true, lang),
sync,
t: lang.pages(),
name: viewer.user.name().to_string(),
groups,
status: Status::new(
&app,
&viewer.user,
viewer.admin,
&stats,
app.control.usage_of(viewer.user.id, library.id).await?,
lang,
),
profile,
devices,
can_write: viewer.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.languages.iter().map(|l| ("language", l.clone())));
query.extend(filter.types.iter().map(|t| ("type", t.clone())));
query.extend(filter.licenses.iter().map(|l| ("license", l.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, Arc<Library>), AppError> {
let not_found = || AppError::NotFound("no such profile".to_string());
if profile::normalize_orcid(id).as_deref() != Some(id) {
return Err(not_found());
}
let user = app
.control
.user_by_orcid(id)
.await?
.filter(|u| !u.disabled && !u.profile_hidden)
.ok_or_else(not_found)?;
let library = app.user_library(&user).await?;
Ok((id.to_string(), library))
}
async fn work_pdfs(app: &AppState, library: &Library) -> Result<HashMap<String, String>, AppError> {
let sources = library.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,
languages: picked("language"),
licenses: picked("license"),
term_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, library) = 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, &library).await?;
let counts = library
.db
.read(|c| store::public_work_facets(c, profile::FACET_VALUES))
.await?;
let terms = profile::WorkTerms::new(library.db.read(store::public_work_terms).await?);
let mut filter = asked.filter.clone();
if asked.param("content") == Some("pdf") {
filter.pdf_keys = Some(pdf_keys(&pdfs));
}
filter.term_keys = terms.keys(&filter.languages, &filter.licenses);
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) = library
.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 = library.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_language = |code: &str| {
let filter = store::WorkFilter {
languages: toggled(&filter.languages, code),
featured: filter.featured && filter.languages.iter().any(|l| l == code),
..filter.clone()
};
links.href(&filter, 1)
};
let with_license = |id: &str| {
let filter = store::WorkFilter {
licenses: toggled(&filter.licenses, id),
featured: filter.featured && filter.licenses.iter().any(|l| l == id),
..filter.clone()
};
links.href(&filter, 1)
};
let german = lang == Lang::De;
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: "language",
heading: t.facet_language,
picked: !filter.languages.is_empty(),
values: terms
.languages()
.iter()
.map(|(code, count)| FacetValue {
label: crate::vocab::language_name(code, german),
count: lang.count(*count),
href: with_language(code),
picked: filter.languages.contains(code),
})
.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: "license",
heading: t.facet_license,
picked: !filter.licenses.is_empty(),
values: terms
.licenses()
.iter()
.map(|(id, count)| FacetValue {
label: id.clone(),
count: lang.count(*count),
href: with_license(id),
picked: filter.licenses.contains(id),
})
.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.languages.iter().map(|code| Chip {
heading: t.facet_language,
label: crate::vocab::language_name(code, german),
href: with_language(code),
}));
chips.extend(filter.types.iter().map(|item_type| Chip {
heading: t.facet_type,
label: lang.item_type(item_type),
href: with_type(item_type),
}));
chips.extend(filter.licenses.iter().map(|id| Chip {
heading: t.facet_license,
label: id.clone(),
href: with_license(id),
}));
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, library) = 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, &library).await?));
}
if !filter.languages.is_empty() || !filter.licenses.is_empty() {
let terms = profile::WorkTerms::new(library.db.read(store::public_work_terms).await?);
filter.term_keys = terms.keys(&filter.languages, &filter.licenses);
}
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) = library
.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> {
let viewer = match may_change(&app, &headers, false).await? {
Ok(viewer) => viewer,
Err(refused) => return Ok(refused),
};
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())),
};
app.control
.set_profile_hidden(viewer.user.id, hidden)
.await?;
Ok(Redirect::to("/").into_response())
}
async fn sync_log(State(app): State<AppState>, headers: HeaderMap) -> Result<Response, AppError> {
if app.cfg.sync.is_none() {
return Err(AppError::NotFound("no such route".to_string()));
}
let Some(viewer) = viewer(&app, &headers).await? else {
return Ok(StatusCode::UNAUTHORIZED.into_response());
};
let Some(run) = app.control.last_run(viewer.user.id).await? else {
return Err(AppError::NotFound("no sync yet".to_string()));
};
let mut log = sync_run_json(&run);
log["results"] = run
.results
.as_deref()
.and_then(|r| serde_json::from_str(r).ok())
.unwrap_or_else(|| json!([]));
log["resultsTotal"] = json!(run.results_total);
let name = format!(
"attachment; filename=\"commonmeta-sync-{}.json\"",
crate::backup::timestamp(run.finished_at.max(0) as u64)
);
let mut response = Json(log).into_response();
let headers = response.headers_mut();
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
if let Ok(value) = HeaderValue::from_str(&name) {
headers.insert(header::CONTENT_DISPOSITION, value);
}
Ok(response)
}
async fn sync_settings(
State(app): State<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
if app.cfg.sync.is_none() {
return Err(AppError::NotFound("no such route".to_string()));
}
let viewer = match may_change(&app, &headers, false).await? {
Ok(viewer) => viewer,
Err(refused) => return Ok(refused),
};
let chosen = |name: &str| form.get(name).is_some_and(|v| v != "false");
let setting = SyncSetting {
enabled: chosen("enabled"),
pdf: chosen("pdf"),
publications: chosen("publications") && viewer.user.orcid.is_some(),
deduplicate: chosen("deduplicate"),
};
app.control
.set_sync_setting(viewer.user.id, setting)
.await?;
Ok(Redirect::to("/").into_response())
}
async fn sync_run(
State(app): State<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
let Some(cfg) = app.cfg.sync.clone() else {
return Err(AppError::NotFound("no such route".to_string()));
};
let viewer = match may_change(&app, &headers, false).await? {
Ok(viewer) => viewer,
Err(refused) => return Ok(refused),
};
let dry_run = match form.get("dry_run").map(String::as_str) {
Some("true") => true,
Some("false") => false,
_ => return Err(AppError::BadRequest("dry_run: true or false".to_string())),
};
let setting = if dry_run {
app.control.sync_setting(viewer.user.id).await?
} else {
match app.control.sync_access(viewer.user.id).await? {
Some(setting) => setting,
None => {
return Err(AppError::BadRequest(
"turn the sync on first, or preview".to_string(),
));
}
}
};
if !crate::sync::start(&app, &cfg, viewer.user, setting, dry_run) {
return Ok(Redirect::to("/?sync=busy").into_response());
}
Ok(Redirect::to("/").into_response())
}
async fn revoke_key(
State(app): State<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
let viewer = match may_change(&app, &headers, false).await? {
Ok(viewer) => viewer,
Err(refused) => return Ok(refused),
};
let id = form
.get("id")
.and_then(|id| id.parse().ok())
.ok_or_else(|| AppError::BadRequest("id: a key's".to_string()))?;
if !app.control.revoke_key(viewer.user.id, id).await? {
return Err(AppError::NotFound("no such key".to_string()));
}
Ok(Redirect::to("/").into_response())
}
fn role_name(role: Role, t: &Pages) -> &'static str {
match role {
Role::Owner => t.role_owner,
Role::Admin => t.role_admin,
Role::Member => t.role_member,
}
}
struct MemberView {
id: u64,
name: String,
orcid: String,
role: &'static str,
me: bool,
removable: bool,
promotable: bool,
admin: bool,
}
#[derive(Template)]
#[template(path = "group.html")]
struct GroupPage {
layout: Layout,
t: &'static Pages,
error: Option<&'static str>,
group: Group,
role: &'static str,
manager: bool,
owner: bool,
members: Vec<MemberView>,
can_write: bool,
}
async fn viewer_group(
app: &AppState,
viewer: &Viewer,
id: &str,
) -> Result<Option<(Group, Vec<Member>, Role)>, AppError> {
let Ok(id) = id.parse::<u64>() else {
return Ok(None);
};
let Some((group, members)) = app.control.group_with_members(id).await? else {
return Ok(None);
};
let role = members
.iter()
.find(|m| m.user.id == viewer.user.id)
.map(|m| m.role);
Ok(role.map(|role| (group, members, role)))
}
async fn group_create(
State(app): State<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
let viewer = match may_change(&app, &headers, false).await? {
Ok(viewer) => viewer,
Err(refused) => return Ok(refused),
};
let name = form
.get("name")
.map(|n| n.trim().chars().take(100).collect::<String>())
.filter(|n| !n.is_empty())
.ok_or_else(|| AppError::BadRequest("name: the group's".to_string()))?;
let group = app.control.create_group(viewer.user.id, name).await?;
Ok(Redirect::to(&format!("/ui/groups/{}", group.id)).into_response())
}
async fn group_page(
State(app): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Extension(lang): Extension<Lang>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Response, AppError> {
let Some(viewer) = viewer(&app, &headers).await? else {
return Ok(Redirect::to("/").into_response());
};
let Some((group, members, role)) = viewer_group(&app, &viewer, &id).await? else {
return Err(AppError::NotFound("no such group".to_string()));
};
let t = lang.pages();
let members = members
.into_iter()
.map(|member| {
let me = member.user.id == viewer.user.id;
MemberView {
id: member.user.id,
name: member.user.name().to_string(),
orcid: member.user.orcid.clone().unwrap_or_default(),
role: role_name(member.role, t),
me,
removable: if me {
member.role != Role::Owner
} else {
role >= Role::Admin && member.role < role
},
promotable: role == Role::Owner && member.role != Role::Owner,
admin: member.role == Role::Admin,
}
})
.collect();
let page_view = GroupPage {
layout: Layout::new(true, lang),
t,
error: match params.get("error").map(String::as_str) {
Some("orcid") => Some(t.not_an_orcid),
Some("user") => Some(t.not_a_user_yet),
_ => None,
},
role: role_name(role, t),
manager: role >= Role::Admin,
owner: role == Role::Owner,
group,
members,
can_write: viewer.access.write,
};
let mut response = page(StatusCode::OK, &page_view.layout, page_view.render());
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(response)
}
async fn group_change(
app: &AppState,
headers: &HeaderMap,
id: &str,
least: Role,
) -> Result<Result<(Viewer, Group, Vec<Member>, Role), Response>, AppError> {
let viewer = match may_change(app, headers, false).await? {
Ok(viewer) => viewer,
Err(refused) => return Ok(Err(refused)),
};
let Some((group, members, role)) = viewer_group(app, &viewer, id).await? else {
return Ok(Err(StatusCode::NOT_FOUND.into_response()));
};
if role < least {
return Ok(Err(StatusCode::FORBIDDEN.into_response()));
}
Ok(Ok((viewer, group, members, role)))
}
async fn group_settings(
State(app): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
let (_, group, _, _) = match group_change(&app, &headers, &id, Role::Admin).await? {
Ok(found) => found,
Err(refused) => return Ok(refused),
};
let name = form
.get("name")
.map(|n| n.trim().chars().take(100).collect::<String>())
.filter(|n| !n.is_empty());
let description = form
.get("description")
.map(|d| d.trim().chars().take(1000).collect::<String>());
let one_of = |field: &str, allowed: &[&str]| -> Result<Option<String>, AppError> {
match form.get(field) {
None => Ok(None),
Some(value) if allowed.contains(&value.as_str()) => Ok(Some(value.clone())),
Some(_) => Err(AppError::BadRequest(format!("{field}: one of {allowed:?}"))),
}
};
let library_editing = one_of("library_editing", &["members", "admins"])?;
let file_editing = one_of("file_editing", &["members", "admins", "none"])?;
app.control
.update_group(group.id, name, description, library_editing, file_editing)
.await?;
Ok(Redirect::to(&format!("/ui/groups/{}", group.id)).into_response())
}
async fn group_member(
State(app): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
let (_, group, members, role) = match group_change(&app, &headers, &id, Role::Admin).await? {
Ok(found) => found,
Err(refused) => return Ok(refused),
};
let back =
|error: &str| Redirect::to(&format!("/ui/groups/{}{error}", group.id)).into_response();
let wanted = match form.get("role").map(String::as_str) {
None | Some("member") => Role::Member,
Some("admin") => Role::Admin,
Some(_) => return Err(AppError::BadRequest("role: member or admin".to_string())),
};
let user = match form.get("orcid") {
Some(orcid) => {
let Some(orcid) = profile::normalize_orcid(orcid) else {
return Ok(back("?error=orcid"));
};
app.control.user_by_orcid(&orcid).await?
}
None => match form.get("user").and_then(|u| u.parse::<u64>().ok()) {
Some(id) if members.iter().any(|m| m.user.id == id) => app.control.user(id).await?,
_ => None,
},
};
let Some(user) = user.filter(|u| !u.disabled) else {
return Ok(back("?error=user"));
};
let current = members
.iter()
.find(|m| m.user.id == user.id)
.map(|m| m.role);
if (wanted == Role::Admin || current == Some(Role::Admin)) && role != Role::Owner {
return Ok(StatusCode::FORBIDDEN.into_response());
}
match app.control.set_member(group.id, user.id, wanted).await? {
Ok(()) => Ok(back("")),
Err(GroupRefusal::Owner) => Ok(StatusCode::FORBIDDEN.into_response()),
Err(_) => Ok(back("?error=user")),
}
}
async fn group_remove(
State(app): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
let (viewer, group, members, role) =
match group_change(&app, &headers, &id, Role::Member).await? {
Ok(found) => found,
Err(refused) => return Ok(refused),
};
let user = form
.get("user")
.and_then(|u| u.parse::<u64>().ok())
.unwrap_or(viewer.user.id);
let Some(target) = members.iter().find(|m| m.user.id == user) else {
return Err(AppError::NotFound("no such member".to_string()));
};
let leaving = user == viewer.user.id;
if !leaving && (role < Role::Admin || target.role >= role) {
return Ok(StatusCode::FORBIDDEN.into_response());
}
match app.control.remove_member(group.id, user).await? {
Ok(()) if leaving => Ok(Redirect::to("/").into_response()),
Ok(()) => Ok(Redirect::to(&format!("/ui/groups/{}", group.id)).into_response()),
Err(_) => Ok(StatusCode::FORBIDDEN.into_response()),
}
}
async fn group_delete(
State(app): State<AppState>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Result<Response, AppError> {
let (_, group, _, _) = match group_change(&app, &headers, &id, Role::Owner).await? {
Ok(found) => found,
Err(refused) => return Ok(refused),
};
app.control.delete_group(group.id).await?;
app.libraries
.forget(crate::control::LibraryRef::Group(group.id))
.await;
Ok(Redirect::to("/").into_response())
}
struct InviteView {
orcid: String,
note: String,
added: String,
used_by: Option<u64>,
}
struct UserView {
id: u64,
name: String,
orcid: String,
usage: String,
quota_mb: String,
devices: i64,
disabled: bool,
access_disabled: bool,
me: bool,
owner: bool,
sync: Option<&'static str>,
sync_last: Option<String>,
sync_failed: bool,
}
struct AccessView {
claim: String,
users: String,
admins: String,
}
#[derive(Template)]
#[template(path = "admin.html")]
struct AdminPage {
layout: Layout,
t: &'static Pages,
error: Option<&'static str>,
access: Option<AccessView>,
invites: Vec<InviteView>,
users: Vec<UserView>,
default_quota: String,
backup: String,
backup_failed: bool,
sync: Option<String>,
sync_failed: bool,
}
async fn admin_page(
State(app): State<AppState>,
headers: HeaderMap,
Extension(lang): Extension<Lang>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Response, AppError> {
let Some(viewer) = viewer(&app, &headers).await? else {
return Ok(Redirect::to("/").into_response());
};
if !viewer.admin {
return Err(AppError::NotFound("no such page".to_string()));
}
let t = lang.pages();
let format_bytes = |n: u64| lang.decimal(&format_bytes(n));
let invites = app
.control
.invites()
.await?
.into_iter()
.map(|invite| InviteView {
orcid: invite.orcid,
note: invite.note.unwrap_or_default(),
added: crate::backup::date(invite.created_at.max(0) as u64),
used_by: invite.used_by,
})
.collect();
let (settings, runs) = match app.cfg.sync {
Some(_) => (
app.control.sync_settings().await?,
app.control.last_runs().await?,
),
None => Default::default(),
};
let users: Vec<UserView> = app
.control
.users()
.await?
.into_iter()
.map(|overview| {
let used = overview.bytes_used.max(0) as u64;
let user = overview.user;
let setting = settings.get(&user.id).copied().unwrap_or_default();
let run = runs.get(&user.id);
UserView {
sync: app.cfg.sync.as_ref().map(|_| match setting {
SyncSetting { enabled: false, .. } => t.sync_off,
SyncSetting { pdf: true, .. } => t.sync_on_pdf,
_ => t.sync_on,
}),
sync_last: run.map(|run| {
format!(
"{} {}, {}",
if run.dry_run {
t.sync_last_preview
} else {
t.sync_last
},
crate::backup::utc_text(run.finished_at.max(0) as u64),
sync_status_word(run.status, t)
)
}),
sync_failed: run.is_some_and(|run| run.status == RunStatus::Failed),
id: user.id,
name: user.name().to_string(),
orcid: user.orcid.clone().unwrap_or_default(),
usage: match app.quota(&user) {
Some(quota) => {
format!("{} {} {}", format_bytes(used), t.of, format_bytes(quota))
}
None => format!("{}, {}", format_bytes(used), t.no_limit),
},
quota_mb: user
.quota_bytes
.map(|b| (b / (1024 * 1024)).to_string())
.unwrap_or_default(),
devices: overview.keys,
disabled: user.disabled,
access_disabled: user.disabled && user.access_disabled,
me: user.id == viewer.user.id,
owner: user.id == app.cfg.user_id,
}
})
.collect();
let (backup, backup_failed) = backup_status(&app, t);
let failed = users.iter().filter(|u| u.sync_failed).count();
let sync = app.cfg.sync.as_ref().map(|_| {
let on = settings.values().filter(|s| s.enabled).count();
let mut line = format!(
"{} {} {} {}",
lang.count(on),
t.of,
lang.count(users.len()),
t.sync_opted_in,
);
let newest = runs
.values()
.filter(|run| !run.dry_run)
.map(|run| run.finished_at)
.max();
if let Some(at) = newest {
line = format!(
"{line}; {} {}",
t.sync_newest,
crate::backup::utc_text(at.max(0) as u64)
);
}
match failed {
0 => line,
n => format!("{line}; {} × {}", lang.count(n), t.sync_last_failed),
}
});
let sync_failed = failed > 0;
let admin = AdminPage {
sync,
sync_failed,
layout: Layout::new(true, lang),
t,
error: match params.get("error").map(String::as_str) {
Some("orcid") => Some(t.not_an_orcid),
Some("user") => Some(t.already_a_user),
Some("quota") => Some(t.not_a_quota),
_ => None,
},
access: app.access_rule().map(|rule| AccessView {
claim: rule.claim.clone(),
users: rule.user_values.join(", "),
admins: rule.admin_values.join(", "),
}),
invites,
users,
default_quota: format_bytes(app.cfg.default_quota),
backup,
backup_failed,
};
let mut response = page(StatusCode::OK, &admin.layout, admin.render());
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(response)
}
async fn admin_invite(
State(app): State<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
let viewer = match may_change(&app, &headers, true).await? {
Ok(viewer) => viewer,
Err(refused) => return Ok(refused),
};
if app.access_rule().is_some() {
return Err(AppError::BadRequest(
"invitations do not apply: the provider's groups or roles decide".to_string(),
));
}
let Some(orcid) = form.get("orcid").and_then(|o| profile::normalize_orcid(o)) else {
return Ok(Redirect::to("/ui/admin?error=orcid").into_response());
};
let note = form
.get("note")
.map(|n| n.trim().chars().take(200).collect::<String>())
.filter(|n| !n.is_empty());
if !app.control.invite(orcid, viewer.user.id, note).await? {
return Ok(Redirect::to("/ui/admin?error=user").into_response());
}
Ok(Redirect::to("/ui/admin").into_response())
}
async fn admin_uninvite(
State(app): State<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
if let Err(refused) = may_change(&app, &headers, true).await? {
return Ok(refused);
}
let orcid = form.get("orcid").cloned().unwrap_or_default();
app.control.uninvite(orcid).await?;
Ok(Redirect::to("/ui/admin").into_response())
}
async fn admin_user(
State(app): State<AppState>,
headers: HeaderMap,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
let viewer = match may_change(&app, &headers, true).await? {
Ok(viewer) => viewer,
Err(refused) => return Ok(refused),
};
let user = match form.get("id").and_then(|id| id.parse::<u64>().ok()) {
Some(id) => app.control.user(id).await?,
None => None,
}
.ok_or_else(|| AppError::NotFound("no such user".to_string()))?;
if let Some(quota) = form.get("quota_mb") {
let quota = quota.trim();
let bytes = if quota.is_empty() {
None
} else {
match quota.parse::<i64>() {
Ok(mb) if (0..=1 << 30).contains(&mb) => Some(mb * 1024 * 1024),
_ => return Ok(Redirect::to("/ui/admin?error=quota").into_response()),
}
};
app.control.set_quota(user.id, bytes).await?;
}
match form.get("disabled").map(String::as_str) {
Some("true") if user.id == viewer.user.id => {
return Err(AppError::BadRequest(
"admins cannot disable themselves".to_string(),
));
}
Some("true") if user.id == app.cfg.user_id => {
return Err(AppError::BadRequest(
"the owner cannot be disabled".to_string(),
));
}
Some("true") => {
app.control.set_disabled(user.id, true).await?;
app.ui_sessions.retain(|session| session.user_id != user.id);
}
Some("false") => app.control.set_disabled(user.id, false).await?,
Some(_) => return Err(AppError::BadRequest("disabled: true or false".to_string())),
None => {}
}
Ok(Redirect::to("/ui/admin").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()
}
fn sync_setting_json(setting: &SyncSetting) -> serde_json::Value {
json!({
"enabled": setting.enabled,
"pdf": setting.pdf,
"publications": setting.publications,
"deduplicate": setting.deduplicate,
})
}
fn sync_run_json(run: &SyncRun) -> serde_json::Value {
json!({
"startedAt": crate::backup::iso_time(run.started_at.max(0) as u64),
"finishedAt": crate::backup::iso_time(run.finished_at.max(0) as u64),
"status": run.status.as_str(),
"dryRun": run.dry_run,
"commonmetaVersion": run.commonmeta_version,
"summary": serde_json::from_str::<serde_json::Value>(&run.summary)
.unwrap_or(serde_json::Value::Null),
"error": run.error,
})
}
async fn status(State(app): State<AppState>, headers: HeaderMap) -> Result<Response, AppError> {
let Some(user) = authorized(&app, &headers).await?.filter(|u| !u.disabled) else {
return Ok((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "an API key or a signed-in session is required" })),
)
.into_response());
};
let library = app.user_library(&user).await?.db.read(store::stats).await?;
let username = user.username.clone();
let mut status = 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": user.id,
"username": username,
"quota": app.quota(&user),
"storage": { "maxUpload": app.cfg.max_upload },
"library": library,
});
if app.cfg.sync.is_some() {
status["sync"] = json!({
"enabled": true,
"setting": sync_setting_json(&app.control.sync_setting(user.id).await?),
"running": app.sync.is_running(user.id),
"lastRun": app.control.last_run(user.id).await?.as_ref().map(sync_run_json),
});
} else {
status["sync"] = json!({ "enabled": false });
}
if !app.is_admin(&user) {
return Ok(Json(status).into_response());
}
if app.cfg.sync.is_some() {
let settings = app.control.sync_settings().await?;
let runs = app.control.last_runs().await?;
let mut ids: Vec<u64> = settings.keys().chain(runs.keys()).copied().collect();
ids.sort_unstable();
ids.dedup();
status["sync"]["users"] = ids
.into_iter()
.map(|id| {
json!({
"userId": id,
"setting": sync_setting_json(&settings.get(&id).copied().unwrap_or_default()),
"lastRun": runs.get(&id).map(sync_run_json),
})
})
.collect();
}
let admin = json!({
"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,
},
});
for (key, value) in admin.as_object().into_iter().flatten() {
status[key] = value.clone();
}
Ok(Json(status).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 a_refused_sign_in_says_whom_to_ask() {
let render = |ask_admin, lang: Lang| {
SignInPage {
layout: Layout::new(false, lang),
t: lang.pages(),
error: Some(lang.pages().not_allowed),
oidc: Some("Keycloak"),
key_login: false,
ask_admin,
}
.render()
.unwrap()
};
let html = render(Some(Some("it@uni.example")), Lang::En);
assert!(
html.contains("write to <a href=\"mailto:it@uni.example?subject=defter\""),
"{html}"
);
let html = render(Some(None), Lang::En);
assert!(html.contains("ask the admins of this server"), "{html}");
assert!(!html.contains("?subject=defter"));
let html = render(Some(Some("it@uni.example")), Lang::De);
assert!(html.contains("schreib an"), "{html}");
let html = render(None, Lang::En);
assert!(
!html.contains("ask the admins") && !html.contains("?subject=defter"),
"{html}"
);
}
#[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");
}
}