5.0 KiB · text History 7a00c02
//! Devices: API tokens for the Android app (and scripts). Minting one shows
//! it once, as a QR of `fieri://pair?base=<server>&token=<token>` that the
//! app scans. The app can also mint its own by signing in with a username
//! and password (`POST /api/auth/login`); those show up here too, labelled
//! with the device name, and revoking one signs that device out.
use axum::extract::{Form, Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, Redirect};
use minijinja::context;
use qrcode::render::svg;
use qrcode::QrCode;
use serde::Deserialize;
use tracing::error;
use uuid::Uuid;

use super::{non_empty, render};
use crate::auth::api_tokens;
use crate::middleware::UserContext;
use crate::AppState;

fn failed(what: &'static str) -> impl FnOnce(anyhow::Error) -> StatusCode {
    move |e| {
        error!(error = %e, "{what}");
        StatusCode::INTERNAL_SERVER_ERROR
    }
}

pub async fn list_devices(
    State(state): State<AppState>,
    user: UserContext,
) -> Result<Html<String>, StatusCode> {
    let tokens = api_tokens::list(state.db.as_ref(), user.user.id).await.map_err(failed("list api tokens failed"))?;
    render(
        &state,
        "devices/list.html",
        context! { title => "Devices", user => user.user, tokens => tokens, now => chrono::Utc::now() },
    )
}

#[derive(Debug, Deserialize)]
pub struct DeviceForm {
    pub label: Option<String>,
    pub days: Option<String>,
}

pub async fn create_device(
    State(state): State<AppState>,
    user: UserContext,
    headers: HeaderMap,
    Form(form): Form<DeviceForm>,
) -> Result<Html<String>, StatusCode> {
    let label = non_empty(form.label);
    let days = form.days.and_then(|d| d.trim().parse::<i64>().ok()).filter(|d| *d > 0);
    let (row, token) =
        api_tokens::create(state.db.as_ref(), user.user.id, label, days).await.map_err(failed("create api token failed"))?;

    let base = base_url(state.config.public_url.as_deref(), &headers, &state.config.bind_address);
    let pair_uri = pair_uri(&base, &token);
    let qr_svg = QrCode::new(pair_uri.as_bytes())
        .map(|c| c.render::<svg::Color>().min_dimensions(280, 280).quiet_zone(true).build())
        .unwrap_or_else(|e| {
            error!(error = %e, "QR render failed");
            String::new()
        });
    render(
        &state,
        "devices/created.html",
        context! {
            title => "Pair a device",
            user => user.user,
            device => row,
            token => token,
            base_url => base,
            pair_uri => pair_uri,
            qr_svg => qr_svg,
        },
    )
}

pub async fn revoke_device(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<Uuid>,
) -> Result<Redirect, StatusCode> {
    api_tokens::revoke(state.db.as_ref(), user.user.id, id).await.map_err(failed("revoke api token failed"))?;
    Ok(Redirect::to("/devices"))
}

/// What the app should dial: `PUBLIC_URL` when set; else the origin the
/// browser used, honouring proxy headers (so a LAN visit to
/// `http://192.168.1.50:4011/devices` pairs the phone with exactly that);
/// else the bind address, which at least shows something to fix.
pub fn base_url(public_url: Option<&str>, headers: &HeaderMap, bind_address: &str) -> String {
    if let Some(url) = public_url {
        return url.to_string();
    }
    let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok()).map(|v| v.split(',').next().unwrap_or(v).trim());
    let scheme = header("x-forwarded-proto").unwrap_or("http");
    match header("x-forwarded-host").or_else(|| header("host")) {
        Some(host) => format!("{scheme}://{host}"),
        None => format!("http://{bind_address}"),
    }
}

/// `fieri://pair?base=…&token=…`, the URI the app's scanner and deep link
/// both understand.
pub fn pair_uri(base: &str, token: &str) -> String {
    let enc = |s: &str| -> String {
        s.bytes()
            .map(|b| match b {
                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
                _ => format!("%{b:02X}"),
            })
            .collect()
    };
    format!("fieri://pair?base={}&token={}", enc(base), enc(token))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn base_url_prefers_public_then_proxy_then_host() {
        let mut h = HeaderMap::new();
        h.insert("host", "192.168.1.50:4011".parse().unwrap());
        assert_eq!(base_url(None, &h, "0.0.0.0:4011"), "http://192.168.1.50:4011");
        h.insert("x-forwarded-proto", "https".parse().unwrap());
        h.insert("x-forwarded-host", "ff.neiam.org".parse().unwrap());
        assert_eq!(base_url(None, &h, "0.0.0.0:4011"), "https://ff.neiam.org");
        assert_eq!(base_url(Some("https://example.org"), &h, "x"), "https://example.org");
        assert_eq!(base_url(None, &HeaderMap::new(), "0.0.0.0:4011"), "http://0.0.0.0:4011");
    }

    #[test]
    fn pair_uri_encodes_the_base() {
        assert_eq!(
            pair_uri("https://ff.neiam.org", "ff_abc"),
            "fieri://pair?base=https%3A%2F%2Fff.neiam.org&token=ff_abc"
        );
    }
}