//! 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"
);
}
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
5.0 KiB · text
History
02765fe