initial commit

This commit is contained in:
yggverse 2025-02-20 05:45:19 +02:00
parent c6f6ec7ba4
commit 86e07f08c7
7 changed files with 408 additions and 0 deletions

34
src/argument.rs Normal file
View file

@ -0,0 +1,34 @@
use clap::Parser;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Argument {
/// Bind server `host:port` to listen incoming connections on it
#[arg(short, long)]
pub bind: String,
/// Filepath to server identity in PKCS (PFX) format
#[arg(short, long)]
pub identity: String,
/// Passphrase to unlock encrypted identity
#[arg(short, long, default_value_t = String::new())]
pub password: String,
/// Uploads directory (e.g. `public` directory)
#[arg(short, long, default_value_t = String::from("public"))]
pub directory: String,
/// Uploads max size limit in bytes (unlimited by default)
#[arg(short, long)]
pub size: Option<usize>,
/// Uploads MIME type allowed (comma separated, all by default)
/// * based on headers
#[arg(short, long)]
pub mime: Option<String>,
/// Redirection URL on request handle complete (e.g. `gemini://localhost`)
#[arg(short, long)]
pub redirect: Option<String>,
}

56
src/header.rs Normal file
View file

@ -0,0 +1,56 @@
use anyhow::{bail, Result};
use native_tls::TlsStream;
use std::{io::Read, net::TcpStream};
pub struct Header {
pub mime: Option<String>,
pub size: usize,
}
impl Header {
pub fn for_stream(stream: &mut TlsStream<TcpStream>) -> Result<Self> {
let mut header: Vec<u8> = Vec::new();
let mut buffer = vec![0];
loop {
match stream.read(&mut buffer) {
Ok(0) => bail!("Invalid protocol"),
Ok(_) => {
if header.len() > 1024 {
bail!("Invalid length")
}
if buffer[0] == b'\r' {
continue;
}
if buffer[0] == b'\n' {
break;
}
header.push(buffer[0])
}
Err(e) => bail!(e),
}
}
Self::from_bytes(header)
}
fn from_bytes(buffer: Vec<u8>) -> Result<Self> {
use anyhow::bail;
use regex::Regex;
let header = String::from_utf8(buffer)?;
Ok(Self {
mime: match Regex::new(r"mime=([^\/]+\/[^\s;]+)")?.captures(&header) {
Some(c) => c.get(1).map(|v| v.as_str().to_string()),
None => None,
},
size: match Regex::new(r"size=(\d+)")?.captures(&header) {
Some(c) => match c.get(1) {
Some(v) => match v.as_str().parse::<usize>() {
Ok(s) => s,
Err(e) => bail!(e),
},
None => bail!("Size required"),
},
None => bail!("Size required"),
},
})
}
}

162
src/main.rs Normal file
View file

@ -0,0 +1,162 @@
mod argument;
mod header;
mod storage;
use anyhow::Result;
use argument::Argument;
use clap::Parser;
use header::Header;
use native_tls::{Identity, TlsAcceptor, TlsStream};
use std::{
fs::File,
io::{Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
sync::Arc,
thread,
time::{SystemTime, UNIX_EPOCH},
};
fn main() -> Result<()> {
let argument = Arc::new(Argument::parse());
// https://geminiprotocol.net/docs/protocol-specification.gmi#the-use-of-tls
let acceptor = TlsAcceptor::new(Identity::from_pkcs12(
&{
let mut buffer = vec![];
File::open(&argument.identity)?.read_to_end(&mut buffer)?;
buffer
},
&argument.password,
)?)?;
let listener = {
println!("[{}] [info] Server started on {}", now(), argument.bind);
TcpListener::bind(&argument.bind)?
};
for stream in listener.incoming() {
match stream {
Ok(stream) => {
thread::spawn({
let argument = argument.clone();
let peer = stream.peer_addr()?;
let stream = acceptor.accept(stream)?;
move || handle(argument, peer, stream)
});
}
Err(e) => println!("[{}] [error] Failed to accept connection: {e}", now()),
}
}
Ok(())
}
fn handle(argument: Arc<Argument>, peer: SocketAddr, mut stream: TlsStream<TcpStream>) {
println!("[{}] [info] [{peer}] New connection", now());
match Header::for_stream(&mut stream) {
Ok(header) => {
// do not trust header values, but check it to continue
if argument.size.is_some_and(|s| header.size > s) {
println!("[{}] [error] [{peer}] Max size limit reached", now());
return;
}
// make sure mime type whitelisted
if argument
.mime
.as_ref()
.is_some_and(|m| header.mime.is_some_and(|h| m.contains(&h)))
{
println!("[{}] [error] [{peer}] MIME type not allowed", now());
return;
}
// begin data handle
let mut total = 0;
let mut buffer = vec![0]; // @TODO optional chunk size
match storage::Item::create(&argument.directory) {
Ok(mut item) => {
loop {
match stream.read(&mut buffer) {
Ok(0) => {
item.delete().unwrap();
println!(
"[{}] [warning] [{peer}] Connection closed with rollback",
now()
);
break;
}
Ok(n) => {
total += n; // validate real data size
if argument.size.is_some_and(|s| s > total) {
item.delete().unwrap();
println!("[{}] [error] [{peer}] Max size limit reached", now());
break;
}
if total > header.size {
item.delete().unwrap();
println!(
"[{}] [error] [{peer}] Content size larger than declared in headers", now()
);
break;
}
if let Err(e) = item.file.write(&buffer) {
item.delete().unwrap();
println!("[{}] [error] [{peer}] Failed to write file from stream: {e}", now());
break;
}
// transfer completed
if total == header.size {
match &stream.write_all(
// https://geminiprotocol.net/docs/protocol-specification.gmi#status-31-permanent-redirection
format!(
"31 {}\r\n",
argument
.redirect
.clone()
.unwrap_or(format!("gemini://{}", argument.bind))
)
.as_bytes(),
) {
Ok(_) => {
item.commit().unwrap(); // @TODO detect/cache mime based on content type received
println!("[{}] [info] [{peer}] Success", now());
// @TODO close connection gracefully
// https://geminiprotocol.net/docs/protocol-specification.gmi#closing-connections
match stream.flush() {
Ok(_) => println!("[{}] [info] [{peer}] Connection closed by server.", now()),
Err(e) => println!("[{}] [error] [{peer}] {e}", now())
};
}
Err(e) => {
item.delete().unwrap();
println!(
"[{}] [error] [{peer}] Failed write to stream: {e}",
now()
)
}
}
break;
}
}
Err(e) => {
item.delete().unwrap();
println!(
"[{}] [error] [{peer}] Failed read from stream: {e}",
now()
);
break;
}
}
}
}
Err(e) => println!("[{}] [error] [{peer}] Could not create Item: {e}", now()),
}
}
Err(e) => println!("[{}] [error] [{peer}] Header error: {e}", now()),
}
}
fn now() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis()
}

85
src/storage.rs Normal file
View file

@ -0,0 +1,85 @@
use anyhow::{bail, Result};
use std::{
fs::{create_dir_all, remove_file, rename, File},
path::{PathBuf, MAIN_SEPARATOR},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
};
const TMP_SUFFIX: &str = ".tmp";
pub struct Item {
pub file: File,
pub path: PathBuf,
// pub id: u64,
}
impl Item {
// Constructors
pub fn create(directory: &str) -> Result<Self> {
loop {
// generate file id from current unix time
let id = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
// build optimized fs path:
// add directory separator after every digit to keep up to 10 files per directory
let path = PathBuf::from(format!(
"{}{MAIN_SEPARATOR}{}{TMP_SUFFIX}",
directory.trim_end_matches(MAIN_SEPARATOR),
id.to_string().chars().fold(String::new(), |mut acc, c| {
if !acc.is_empty() {
acc.push(MAIN_SEPARATOR);
}
acc.push(c);
acc
})
));
// recursively create directories
// * parent directory is expected
create_dir_all(path.parent().unwrap())?;
// build `Self`
match File::create_new(&path) {
// make sure slot is not taken (by another thread)
Ok(file) => {
return Ok(Self {
file,
path,
// id
});
}
Err(_) => {
println!("[warning] Could not init location: {:?}", path.to_str());
// find free slot after some delay..
thread::sleep(Duration::from_secs(1));
continue;
}
}
}
}
// Actions
/// Take object processed, commit its changes
pub fn commit(self) -> Result<()> {
match self.path.to_str() {
Some(old) => match old.strip_suffix(TMP_SUFFIX) {
Some(new) => Ok(rename(old, new)?),
None => bail!("Invalid TMP suffix"), // | panic
},
None => bail!("Invalid Item path"), // | panic
}
}
/// Cleanup object relationships
pub fn delete(self) -> Result<()> {
Ok(remove_file(self.path)?)
}
// Getters
/* @TODO implement short links handle without slash
pub fn alias(&self, relative: &str) -> String {} */
}