stargazer 1.2.1

A fast and easy to use Gemini server
// stargazer - A Gemini Server
// Copyright (C) 2021 Sashanoraa <sasha@noraa.gay>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

use crate::error::{bad_req, error, Context, ErrorConv, GemError, Result};
use crate::CONF;
use async_net::SocketAddr;
use regex::Captures;
use std::convert::From;
use std::convert::TryFrom;
use std::path::PathBuf;
use uzers::User;
use x509_parser::prelude::X509Certificate;

#[derive(Debug, Clone)]
pub struct Route {
    /// Domain for site
    pub domain: String,
    /// Routing directive
    pub path: RoutePath,
    /// Rewrite rule
    pub rewrite: Option<String>,
    pub lang: Option<String>,
    pub charset: Option<String>,
    pub route_type: RouteType,
    pub cert_key_path: Option<(PathBuf, PathBuf)>,
    pub client_cert: Option<X509Certificate<'static>>,
}

#[derive(Debug, Clone)]
pub enum RouteType {
    Static(StaticRoute),
    Cgi(CGIRoute),
    Scgi(SCGIRoute),
    Redirect(RedirectRoute),
}

impl Display for RouteType {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        match self {
            Self::Static(_) => write!(f, "static"),
            Self::Cgi(_) => write!(f, "cgi"),
            Self::Scgi(_) => write!(f, "scgi"),
            Self::Redirect(_) => write!(f, "redirect"),
        }
    }
}

#[derive(Debug, Clone)]
pub struct StaticRoute {
    /// Path to serve files from
    pub root: PathBuf,
    /// Index file name
    pub index: String,
    /// Auto generate index
    pub auto_index: bool,
    /// Override the mime type for files in this route
    pub mime_override: Option<String>,
}

impl From<StaticRoute> for RouteType {
    fn from(route: StaticRoute) -> Self {
        RouteType::Static(route)
    }
}

#[derive(Debug, Clone)]
pub struct CGIRoute {
    /// Path to serve files from
    pub root: PathBuf,
    /// Index file name
    pub index: String,
    /// User to run CGI process as
    pub user: Option<User>,
    /// Timeout for request to complete
    pub timeout: Option<u64>,
}

impl From<CGIRoute> for RouteType {
    fn from(route: CGIRoute) -> Self {
        RouteType::Cgi(route)
    }
}

#[derive(Debug, Clone)]
pub struct SCGIRoute {
    /// SCGI address (either unix socket of TCP)
    pub addr: SCGIAddress,
    /// Timeout for request to complete
    pub timeout: Option<u64>,
}

impl From<SCGIRoute> for RouteType {
    fn from(route: SCGIRoute) -> Self {
        RouteType::Scgi(route)
    }
}

#[derive(Debug, Clone)]
pub enum SCGIAddress {
    Unix(PathBuf),
    Tcp(SocketAddr),
}

#[derive(Debug, Clone)]
pub struct RedirectRoute {
    pub url: String,
    pub permanent: bool,
    pub redirect_rewrite: bool,
}

impl From<RedirectRoute> for RouteType {
    fn from(route: RedirectRoute) -> Self {
        RouteType::Redirect(route)
    }
}

#[derive(Debug, Clone)]
pub enum RoutePath {
    All,
    Prefix(String),
    Exact(String),
    Regex(Box<regex::Regex>),
}

pub fn route(uri: &str) -> Result<(&'static Route, Request)> {
    let mut req = Request::from_uri(uri)?;

    let mut domain_found = false;

    for site in &CONF.routes {
        if site.domain != req.host {
            continue;
        }
        domain_found = true;
        if match &site.path {
            RoutePath::All => true,
            RoutePath::Prefix(s) => req.path.starts_with(s),
            RoutePath::Exact(s) => req.path == *s,
            RoutePath::Regex(r) => {
                if let Some(captures) = r.captures(&req.path) {
                    if let Some(rewrite) = &site.rewrite {
                        if rewrite.contains('\\') {
                            let new_path = do_rewrite(rewrite, captures)
                                .map_err(|e| {
                                    GemError::ServerError(
                                        e.unwrap_err().context(format!(
                                            "Error doing rewrite {rewrite}"
                                        )),
                                    )
                                })?;
                            req.path = new_path;
                        } else {
                            req.path.clone_from(rewrite);
                        };
                    }
                    true
                } else {
                    false
                }
            }
        } {
            return Ok((site, req));
        }
    }
    if domain_found {
        Err(GemError::NotFound)
    } else {
        Err(GemError::NoProxy(anyhow::anyhow!(
            "No route for host: {}",
            req.host
        )))
    }
}

fn do_rewrite(input: &str, captures: Captures<'_>) -> Result<String> {
    #[derive(PartialEq, Eq, Debug)]
    enum State {
        Text,
        Capture,
        Named(String),
    }
    let mut state = State::Text;
    let mut output = String::with_capacity(input.len());
    for c in input.chars() {
        match &mut state {
            State::Text if c == '\\' => state = State::Capture,
            State::Text => output.push(c),
            State::Capture if c.is_ascii_digit() => {
                let idx = c.to_digit(10).unwrap() as usize;
                let capture = captures.get(idx).with_context(|| {
                    format!("Url didn't contain capture `{idx}`")
                })?;
                output.push_str(capture.as_str());
                state = State::Text;
            }
            State::Capture if c == '{' => state = State::Named(String::new()),
            // Backslash followed by a char that isn't a number or '{' is invalid
            State::Capture => return Err(error("Invalid rewrite directive")),
            State::Named(s) if c == '}' => {
                let capture = captures.name(s).with_context(|| {
                    format!("Url didn't contain capture `{s}`")
                })?;
                output.push_str(capture.as_str());
                state = State::Text;
            }
            State::Named(s) => {
                s.push(c);
            }
        }
    }
    if state != State::Text {
        return Err(error(
            "Invalid rewrite directive, incomplete capture directive",
        ));
    }
    Ok(output)
}

#[derive(Debug, Clone)]
pub struct Request {
    pub uri: String,
    pub path: String,
    pub query: String,
    pub host: String,
}

impl Request {
    fn from_uri(uri_str: &str) -> Result<Request> {
        use uriparse::URIReference;

        if uri_str.is_empty() {
            return Err(bad_req("Empty URL"));
        }

        let mut uri = URIReference::try_from(uri_str)
            .with_context(|| format!("Bad URI: {uri_str}"))
            .into_bad_req()?;

        let mut path = uri.path().to_owned();
        let authority = uri
            .authority()
            .context("Username and password URI fields not allowed")
            .into_bad_req()?;
        if authority.has_password() || authority.has_username() {
            return Err(bad_req(
                "Username and password URI fields not allowed",
            ));
        }
        let host = authority.host().to_string().to_lowercase();
        let port = authority.port();
        let scheme = uri
            .scheme()
            .context("URI mush have a scheme")
            .into_bad_req()?;
        if scheme.as_str() != "gemini" {
            return Err(GemError::NoProxy(anyhow::anyhow!(
                "Schema {scheme} not supported"
            )));
        }

        if let Some(port) = port {
            if !CONF
                .listen
                .iter()
                .fold(false, |found, addr| (addr.port() == port) || found)
            {
                return Err(GemError::NoProxy(anyhow::anyhow!(
                    "Port {port} not listened on",
                )));
            }
        }

        if host.is_empty() {
            return Err(bad_req("No relative URLs"));
        }

        if !path.is_normalized(false) {
            path.normalize(false);
            uri.set_path(path).context("Path normalization failed")?;
            return Err(GemError::Redirect(uri.to_string()));
        }
        let query = uri.query().map(|v| v.as_str()).unwrap_or("").to_owned();

        Ok(Request {
            uri: uri.to_string(),
            host,
            query,
            path: path.to_string(),
        })
    }
}

use std::fmt::{self, Display, Formatter};

impl Display for Request {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        self.uri.fmt(f)
    }
}