use axum::{
Json, Router,
extract::{Path, State},
http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response},
routing::get,
};
use serde_json::{Value, json};
use crate::app::AppState;
use crate::error::AppError;
use crate::{auth, store, zotero};
mod assets {
include!(concat!(env!("OUT_DIR"), "/ui_assets.rs"));
}
const INDEX_HTML: &str = include_str!("../ui/dist/index.html");
pub fn routes() -> Router<AppState> {
Router::new()
.route("/", get(index))
.route("/login", get(auth::login_page).post(auth::login_authorize))
.route("/assets/{name}", get(asset))
.route("/ui/status", get(status))
}
async fn index(State(app): State<AppState>) -> Response {
let mut response = (zotero::server_headers(&app.server_id), Html(INDEX_HTML)).into_response();
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginView<'a> {
Pending(&'a str),
Approved,
Expired,
Missing,
Refused(&'a str),
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
fn stylesheet() -> &'static str {
INDEX_HTML
.split('"')
.find(|part| part.starts_with("/assets/") && part.ends_with(".css"))
.unwrap_or("")
}
fn favicon() -> &'static str {
INDEX_HTML
.split('"')
.find(|part| part.starts_with("data:image/svg+xml"))
.unwrap_or("")
}
const HEADING: &str = "text-xl font-semibold tracking-tight";
const TEXT: &str = "text-sm text-muted-foreground";
fn login_body(view: LoginView) -> (&'static str, String) {
let message = |title: &'static str, text: &str| {
(
title,
format!(r#"<h1 class="{HEADING}">{title}</h1><p class="{TEXT}">{text}</p>"#),
)
};
match view {
LoginView::Pending(token) => (
"Authorize Zotero?",
format!(
r#"<h1 class="{HEADING}">Authorize Zotero?</h1>
<p class="{TEXT}">A Zotero client asked to sync with this library. Approve only if you just started the login yourself.</p>
<form method="post" action="/login">
<input type="hidden" name="session" value="{}">
<button type="submit" class="w-full rounded-md bg-primary px-4 py-2 font-medium text-primary-foreground hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring">Approve</button>
</form>"#,
escape(token)
),
),
LoginView::Approved => message(
"Authorized",
"You can close this page and return to Zotero.",
),
LoginView::Expired => message(
"Login expired",
"This login request is unknown or has expired. Start the login in Zotero again.",
),
LoginView::Missing => message(
"No login request",
"Open this page from Zotero’s Settings → Sync → Log In.",
),
LoginView::Refused(reason) => message("Not authorized", &escape(reason)),
}
}
pub fn login_page(status: StatusCode, view: LoginView) -> Response {
let nonce = auth::random_hex(16);
let (title, body) = login_body(view);
let html = format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>{title} — defter</title>
<link rel="icon" type="image/svg+xml" href="{favicon}">
<link rel="stylesheet" href="{stylesheet}">
<script nonce="{nonce}">
if (localStorage.getItem('defter-dark') === 'true' || (localStorage.getItem('defter-dark') === null && matchMedia('(prefers-color-scheme: dark)').matches)) document.documentElement.classList.add('dark')
</script>
</head>
<body>
<div class="min-h-screen flex flex-col">
<header class="border-b">
<div class="mx-auto max-w-4xl px-4 h-14 flex items-center">
<a href="/" class="flex items-center gap-2 font-semibold tracking-tight"><span class="inline-block size-6 rounded-md bg-primary"></span>defter</a>
</div>
</header>
<main class="flex-1 mx-auto w-full max-w-4xl px-4 py-8">
<div class="mx-auto max-w-md rounded-lg border bg-card p-6 space-y-4">
{body}
</div>
</main>
</div>
</body>
</html>
"#,
favicon = escape(favicon()),
stylesheet = escape(stylesheet()),
);
let csp = format!(
"default-src 'none'; style-src 'self'; img-src data:; script-src 'nonce-{nonce}'; \
form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
);
let mut response = (status, Html(html)).into_response();
let headers = response.headers_mut();
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
if let Ok(csp) = HeaderValue::from_str(&csp) {
headers.insert(header::CONTENT_SECURITY_POLICY, csp);
}
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>) -> Result<Json<Value>, AppError> {
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,
})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_html() {
assert_eq!(
escape(r#"<a href="x">'&'</a>"#),
"<a href="x">'&'</a>"
);
}
#[test]
fn finds_the_built_stylesheet_and_favicon() {
assert!(stylesheet().starts_with("/assets/") && stylesheet().ends_with(".css"));
assert!(favicon().starts_with("data:image/svg+xml"));
}
#[test]
fn the_session_token_is_escaped_in_the_form() {
let (_, body) = login_body(LoginView::Pending("\"><script>"));
assert!(body.contains(r#"value=""><script>""#));
assert!(!body.contains("<script>"));
}
}