defter 0.2.0

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! The web UI, rendered on the server from the askama templates in
//! `templates/`: the dashboard and the login consent page. htmx refreshes the
//! dashboard's status in place. It and the Tailwind stylesheet are built from
//! `ui/` by `build.rs` and embedded in the binary. Every page works without
//! JavaScript and is served under a strict Content-Security-Policy.

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};

/// The stylesheet and script, under names carrying their content hash
/// (`STYLESHEET`, `SCRIPT`, `UI_ASSETS`), generated by `build.rs`.
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))
}

// ── layout ───────────────────────────────────────────────────────────────────────

/// What `templates/base.html` needs.
struct Layout {
    stylesheet: &'static str,
    script: &'static str,
    /// Allows the one inline script, the theme switch, under the CSP.
    nonce: String,
}

impl Layout {
    fn new() -> Self {
        Layout {
            stylesheet: assets::STYLESHEET,
            script: assets::SCRIPT,
            nonce: auth::random_hex(16),
        }
    }

    /// Scripts and styles only from this server (plus the theme switch's
    /// nonce), requests only to it, and no framing, so a button cannot be
    /// clickjacked.
    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
        )
    }
}

/// A rendered page, with the headers every page carries.
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
}

// ── dashboard ────────────────────────────────────────────────────────────────

struct Stat {
    label: &'static str,
    value: String,
}

/// What the dashboard's status block shows.
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,
}

/// `GET /`: the dashboard. htmx's poll (`HX-Request`) gets only the status
/// block. The page also carries the server headers, so `/` identifies the
/// server the way `/api/` does.
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"));
    // The same URL answers with a page or a fragment.
    headers.insert(header::VARY, HeaderValue::from_static("HX-Request"));
    Ok(response)
}

/// A count with thousands separators: 12345 → "12,345".
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 }
}

/// A byte count for people: 1536 → "1.5 KB".
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])
    }
}

// ── login page ───────────────────────────────────────────────────────────────

/// What the login page shows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginView<'a> {
    /// A pending session, with the Approve form.
    Pending(&'a str),
    Approved,
    /// Unknown or expired session.
    Expired,
    /// No session in the URL.
    Missing,
    /// The approval was refused, and why.
    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>,
}

/// The login page as a complete response. Besides the page headers it is
/// never cached and sends no referrer, as its URL carries the session token.
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
}

// ── assets and status ────────────────────────────────────────────────────────

/// An embedded asset. Its name carries its content hash, so it can be cached
/// for good.
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()
}

/// `GET /ui/status`: the dashboard's data as JSON, for monitoring. Like the
/// pages, a deployment keeps it behind SSO or a private network.
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="&#34;&#62;&#60;script&#62;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");
    }
}