use askama::Template;
use axum::{
Json, Router,
extract::{Path, State},
http::{HeaderMap, 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"));
}
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))
}
struct Layout {
stylesheet: &'static str,
script: &'static str,
nonce: String,
}
impl Layout {
fn new() -> Self {
Layout {
stylesheet: assets::STYLESHEET,
script: assets::SCRIPT,
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),
}
}
}
#[derive(Template)]
#[template(path = "dashboard.html", blocks = ["status"])]
struct DashboardPage {
layout: Layout,
status: Status,
}
async fn dashboard(State(app): State<AppState>, request: HeaderMap) -> Result<Response, AppError> {
let library = app.db.read(store::stats).await?;
let dashboard = DashboardPage {
layout: Layout::new(),
status: Status::new(&app, &library),
};
let fragment = request.get("hx-request").is_some_and(|v| v == "true")
&& !request.contains_key("hx-history-restore-request");
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-cache"));
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(&'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 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(),
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("no-referrer"),
);
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 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(),
view: LoginView::Pending("\"><script>alert(1)</script>"),
}
.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");
}
}