omabeam 0.1.0

Beam text, links, or one file to a phone with a QR code
//! Serves one open file on one LAN address and one tokenized download route.

use std::fs::{self, File};
use std::io::{self, Seek, Write};
use std::net::Ipv4Addr;
use std::path::Path;
use std::process::Command;

use tiny_http::{Header, Method, Request, Response, Server};

use super::qr;

const PORT: u16 = 61_234;

pub(super) fn serve(path: &Path) -> Result<(), String> {
    let file =
        File::open(path).map_err(|error| format!("could not open {}: {error}", path.display()))?;
    if !file
        .metadata()
        .map_err(|error| format!("could not inspect {}: {error}", path.display()))?
        .is_file()
    {
        return Err(format!("{} is not a regular file", path.display()));
    }

    let address = local_ipv4()?;
    let server = Server::http((address, PORT))
        .map_err(|error| format!("could not start the file server on port {PORT}: {error}"))?;
    let route = format!("/{}/download", route_token()?);
    let url = format!("http://{address}:{PORT}{route}");
    let filename = safe_filename(path);
    let content_type = mime_guess::from_path(path)
        .first_raw()
        .unwrap_or("application/octet-stream");

    println!("Sharing {} at:", path.display());
    println!("{url}");
    qr::print(&url)?;
    println!("Press Ctrl-C to stop.");
    io::stdout()
        .flush()
        .map_err(|error| format!("could not print the QR code: {error}"))?;

    for request in server.incoming_requests() {
        if let Err(error) = serve_request(request, &file, &filename, content_type, &route) {
            eprintln!("omabeam: could not serve request: {error}");
        }
    }

    Ok(())
}

fn serve_request(
    request: Request,
    file: &File,
    filename: &str,
    content_type: &str,
    route: &str,
) -> io::Result<()> {
    if request.method() != &Method::Get {
        let allow = Header::from_bytes("Allow", "GET").expect("static header is valid");
        return request.respond(Response::empty(405).with_header(allow));
    }

    if request.url() != route {
        return request.respond(Response::empty(404));
    }

    let mut download = file.try_clone()?;
    download.rewind()?;
    let content_type =
        Header::from_bytes("Content-Type", content_type).expect("MIME type is a valid header");
    let disposition = Header::from_bytes(
        "Content-Disposition",
        format!("inline; filename=\"{filename}\""),
    )
    .expect("safe filename makes a valid header");

    request.respond(
        Response::from_file(download)
            .with_header(content_type)
            .with_header(disposition),
    )
}

fn route_token() -> Result<String, String> {
    let token = fs::read_to_string("/proc/sys/kernel/random/uuid")
        .map_err(|error| format!("could not generate a download token: {error}"))?;
    let token = token.trim();

    if token.is_empty()
        || !token
            .bytes()
            .all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
    {
        return Err("the kernel returned an invalid download token".into());
    }

    Ok(token.to_owned())
}

fn local_ipv4() -> Result<Ipv4Addr, String> {
    let output = Command::new("ip")
        .args(["-4", "route", "show", "default"])
        .output()
        .map_err(|error| format!("could not run ip; is iproute2 installed? ({error})"))?;
    if !output.status.success() {
        return Err(format!(
            "could not inspect the local network: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        ));
    }

    let routes = String::from_utf8(output.stdout)
        .map_err(|_| "ip returned an invalid default route".to_owned())?;
    let route = routes.lines().next().ok_or_else(|| {
        "could not find a LAN address; connect to a local network first".to_owned()
    })?;
    let address = route
        .split_whitespace()
        .zip(route.split_whitespace().skip(1))
        .find_map(|(word, next)| (word == "src").then_some(next))
        .ok_or_else(|| "the default network route has no source address".to_owned())?
        .parse::<Ipv4Addr>()
        .map_err(|error| format!("the default network route has an invalid address: {error}"))?;

    if address.is_loopback() || address.is_unspecified() {
        Err("could not find a LAN address; connect to a local network first".into())
    } else {
        Ok(address)
    }
}

fn safe_filename(path: &Path) -> String {
    let filename = path
        .file_name()
        .unwrap_or_default()
        .to_string_lossy()
        .chars()
        .map(|character| match character {
            'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' | '_' => character,
            _ => '_',
        })
        .collect::<String>();

    if filename.is_empty() {
        "download".into()
    } else {
        filename
    }
}