defter 0.1.0

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! The web UI: a Svelte dashboard (built from `ui/` by `build.rs` and embedded
//! in the binary) and the JSON it reads, plus the server-rendered login
//! consent page. That page needs no JavaScript and shares the dashboard's
//! Tailwind stylesheet: `ui/src/app.css` scans this file for class names.

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

/// The app's page. It also carries the server headers, so `/` identifies the
/// server the way `/api/` does.
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
}

// ── 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),
}

/// Escape text for HTML content and attribute values.
fn escape(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for c in text.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            c => out.push(c),
        }
    }
    out
}

/// The stylesheet the Vite build emitted, as `index.html` names it.
fn stylesheet() -> &'static str {
    INDEX_HTML
        .split('"')
        .find(|part| part.starts_with("/assets/") && part.ends_with(".css"))
        .unwrap_or("")
}

/// The favicon `index.html` declares (a data: URI).
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)),
    }
}

/// The login page as a complete response. It runs no script but the theme
/// switch (allowed by a per-response nonce), cannot be framed (so the Approve
/// button cannot be clickjacked), is never cached, and sends no referrer (its
/// URL carries the session token).
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
}

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

/// A built asset. Vite names each by 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`: what the dashboard shows. Like `/login`, 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 escapes_html() {
        assert_eq!(
            escape(r#"<a href="x">'&'</a>"#),
            "&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;"
        );
    }

    #[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="&quot;&gt;&lt;script&gt;""#));
        assert!(!body.contains("<script>"));
    }
}