5.0 KiB · text History c20d4f7
use axum::{
    extract::{Form, Path, State},
    http::{HeaderMap, StatusCode},
    response::{Html, Redirect},
};
use minijinja::context;
use qrcode::render::svg;
use qrcode::QrCode;
use serde::Deserialize;
use tracing::error;

use crate::auth;
use crate::middleware::UserContext;
use crate::AppState;

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

pub async fn show_api_tokens(
    State(state): State<AppState>,
    user: UserContext,
) -> Result<Html<String>, StatusCode> {
    let tokens = auth::list_api_tokens(state.db.as_ref(), user.user.id)
        .await
        .map_err(|e| {
            error!(error = %e, "list api tokens failed");
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let template = state.env.get_template("api_tokens/list.html")
        .map_err(|e| { error!(error = %e, "load api_tokens/list.html failed"); StatusCode::INTERNAL_SERVER_ERROR })?;

    let rendered = template
        .render(context! {
            title => "API tokens",
            user => user.user,
            tokens => tokens,
            now => chrono::Utc::now(),
        })
        .map_err(|e| { error!(error = %e, "render api_tokens/list.html failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
    Ok(Html(rendered))
}

pub async fn create_api_token(
    State(state): State<AppState>,
    user: UserContext,
    headers: HeaderMap,
    Form(form): Form<CreateTokenForm>,
) -> Result<Html<String>, StatusCode> {
    let label = form.label.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
    let days = form.days.filter(|d| *d > 0);

    let token = auth::create_api_token(state.db.as_ref(), user.user.id, label, days)
        .await
        .map_err(|e| {
            error!(error = %e, "create api token failed");
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let base = derive_base_url(&headers, &state.config.bind_address);
    let pair_uri = format!(
        "dms://pair?base={}&token={}",
        urlencoded(&base),
        urlencoded(&token.token),
    );
    let qr_svg = render_qr_svg(&pair_uri).unwrap_or_else(|e| {
        error!(error = %e, "QR render failed");
        String::new()
    });

    let template = state.env.get_template("api_tokens/created.html")
        .map_err(|e| { error!(error = %e, "load api_tokens/created.html failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
    let rendered = template
        .render(context! {
            title => "API token created",
            user => user.user,
            token => token,
            pair_uri => pair_uri,
            base_url => base,
            qr_svg => qr_svg,
        })
        .map_err(|e| { error!(error = %e, "render api_tokens/created.html failed"); StatusCode::INTERNAL_SERVER_ERROR })?;
    Ok(Html(rendered))
}

pub async fn delete_api_token(
    State(state): State<AppState>,
    user: UserContext,
    Path(id): Path<uuid::Uuid>,
) -> Result<Redirect, StatusCode> {
    auth::delete_api_token(state.db.as_ref(), user.user.id, id)
        .await
        .map_err(|e| {
            error!(error = %e, "delete api token failed");
            StatusCode::INTERNAL_SERVER_ERROR
        })?;
    Ok(Redirect::to("/api-tokens"))
}

/// Pick the public-facing base URL the Android app should hit, in priority:
/// 1. `DMS_PUBLIC_URL` env (explicit override for reverse-proxy setups)
/// 2. `X-Forwarded-Proto` + `X-Forwarded-Host` (standard proxy headers)
/// 3. The request's `Host` header (covers the common case of LAN access —
///    visiting `http://192.168.1.50:3007/api-tokens` makes the QR encode
///    that exact origin so the phone can dial it back)
/// 4. Bind address as a last resort (will be `0.0.0.0:…` and unusable, but
///    at least there's something in the QR for the user to inspect)
fn derive_base_url(headers: &HeaderMap, bind_address: &str) -> String {
    if let Ok(v) = std::env::var("DMS_PUBLIC_URL") {
        return v.trim_end_matches('/').to_string();
    }
    let scheme = headers
        .get("x-forwarded-proto")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
        .unwrap_or_else(|| "http".to_string());
    let host = headers
        .get("x-forwarded-host")
        .or_else(|| headers.get(axum::http::header::HOST))
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());
    match host {
        Some(h) => format!("{scheme}://{h}").trim_end_matches('/').to_string(),
        None => format!("http://{bind_address}"),
    }
}

fn render_qr_svg(payload: &str) -> Result<String, qrcode::types::QrError> {
    let code = QrCode::new(payload.as_bytes())?;
    Ok(code
        .render::<svg::Color>()
        .min_dimensions(280, 280)
        .quiet_zone(true)
        .build())
}

fn urlencoded(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!("%{:02X}", b),
        })
        .collect()
}