// stargazer - A Gemini Server
// Copyright (C) 2021 Ben Aaron Goldberg <ben@benaaron.dev>
//
// 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::{Context, ErrorConv, GemError, Result};
use crate::router::StaticRoute;
use crate::{dir_list, LogInfo};
use async_fs::File;
use async_net::TcpStream;
use blocking::unblock;
use futures_lite::io::{AsyncReadExt, AsyncWriteExt};
use futures_rustls::server::TlsStream;
use minijinja::{context, Template};
use os_str_bytes::OsStringBytes;
use percent_encoding::percent_decode_str;
use std::{ffi::OsString, path::Path};
const DEFAULT_MIME: &str = "application/octet-stream";
const GMI_MIME: &str = "text/gemini";
#[derive(Debug, PartialEq, Eq)]
enum PathType {
Dir,
File,
}
pub async fn get_file<'a>(
static_route: &'static StaticRoute,
path: &'a str,
lang: &'a Option<String>,
charset: &'a Option<String>,
stream: &'a mut TlsStream<TcpStream>,
) -> Result<LogInfo> {
let path_str = path;
let os_str = parse_path(path)?;
let path = static_route.root.join(Path::new(&os_str));
log::debug!("Requested file path: {}", path.display());
let mut path_string = path_str.to_owned();
let template_info = get_template(static_route, &path);
let has_template = template_info.is_some();
let (path, path_type, mut mime_type) = unblock(move || {
if !path.exists() && !has_template {
return Err(GemError::NotFound);
}
if path.is_dir() {
// Redirect directories to have a trailing slash
if !path_string.ends_with('/') {
path_string.push('/');
return Err(GemError::Redirect(path_string));
}
let index_path = path.join(&static_route.index);
if index_path.exists() {
let mime_type = get_mime_type(&index_path);
return Ok((index_path, PathType::File, mime_type));
}
Ok((path, PathType::Dir, GMI_MIME.to_owned()))
} else {
let mime_type = get_mime_type(&path);
Ok((path, PathType::File, mime_type))
}
})
.await?;
if path_type == PathType::Dir && !static_route.auto_index {
return Err(GemError::NotFound);
}
// Add lang or charset to mime_type
match (lang, charset) {
(Some(lang), _) if mime_type == GMI_MIME => {
mime_type.push_str("; lang=");
mime_type.push_str(lang);
}
(_, Some(charset)) if mime_type.starts_with("text/") => {
mime_type.push_str("; charset=");
mime_type.push_str(charset);
}
_ => {}
}
let mut buf = [0u8; 4096];
stream.write_all(b"20 ").await.into_io_error()?;
stream
.write_all(mime_type.as_bytes())
.await
.into_io_error()?;
stream.write_all(b"\r\n").await.into_io_error()?;
let mut body_size = 0usize;
match path_type {
PathType::File => match template_info {
Some((template, path)) => {
let rendered =
unblock(move || template.render(context!(path => path))).await?;
stream
.write_all(rendered.as_bytes())
.await
.context("Error writing output")
.into_io_error()?;
body_size = rendered.len();
}
None => {
if static_route.templates.is_some()
&& path
.extension()
.map(|ext| ext == "tmpl")
.unwrap_or(false)
{
log::debug!("Don't server template file");
return Err(GemError::NotFound);
}
let mut file = File::open(&path).await.with_context(|| {
format!("Error opening file: {}", path.display())
})?;
loop {
let bytes_read =
file.read(&mut buf).await.with_context(|| {
format!(
"Error reading from file: {}",
path.display()
)
})?;
if bytes_read == 0 {
break;
}
stream
.write_all(&buf[..bytes_read])
.await
.context("Error writing output")
.into_io_error()?;
body_size += bytes_read;
}
}
},
PathType::Dir => {
body_size = dir_list::gen(&path, stream).await?;
}
}
Ok(LogInfo {
status: 20,
meta: mime_type.into_bytes(),
size: body_size,
})
}
pub fn parse_path(percent_encoded: &str) -> Result<OsString> {
let mut bytes: Vec<u8> = percent_decode_str(percent_encoded).collect();
// Remove leading slashes if present
while !bytes.is_empty() && bytes[0] == b'/' {
bytes.remove(0);
}
OsString::from_raw_vec(bytes)
.context("File path is not a valid encoding for your OS platform")
}
fn get_mime_type(path: &Path) -> String {
if let Some(ext) = path.extension() {
match ext.to_str() {
Some(ext) => {
if ext == "gmi" || ext == "gemini" {
GMI_MIME.to_owned()
} else {
mime_guess::from_ext(ext)
.first_raw()
.map(|s| s.to_owned())
.unwrap_or_else(|| DEFAULT_MIME.to_owned())
}
}
None => DEFAULT_MIME.to_owned(),
}
} else {
DEFAULT_MIME.to_owned()
}
}
fn get_template<'a>(
route: &'a StaticRoute,
path: &Path,
) -> Option<(Template<'a>, String)> {
let templates = route.templates.as_ref()?;
let path = path.to_str()?.to_owned();
templates
.get_template(&path)
.ok()
.map(|template| (template, path))
}