3VVQWLOXWDG4PFA3GCR4QFRKHHNZGK52KWMPAOSDN734DABSJZVQC XGDABCJNB4TBNPPDFFWXWU5NIEEDWB26GOETJMMEVEBWVCF2UWHAC FZIABKEV74PFFRFETMT5WVL625I76UYN2LXKIKRZNQE5GDGX3GUQC DBKKKHC2ER7XAYR6HAFQ6I75M3OLDOSGCB5BTEP7X6XAZRSWKIRAC 3SPNKI46URBW4PD3JRMBV5YV5W56N3SFUTK25VQEF2FUZ64ZDQPAC WGRFJRTEXOKY7PV536O62WUE32T24VQEHFQP67GEZZ67AE37ZWUAC HVFBGX2A2LBL6HSFKPNIG5KGQPVMZ5GFYJ4ZPDAM4POSRTQSKN7QC AMTMTTJTF2XHEUC6ACP2PHP7GAFMFZRSJ47VJBKEO22X4ZGFIX7QC ARXVO5XAQFZR3X6MXW525Z6WXUYUHRJUGTTNCEKKXRAKQQBG5ZXAC AOJXTWBZA2DQBFTJZX2YTGAKIB62SKTEVJ7RRIBJNDSMC75DDQVQC ONQEIR5BV26QJSI3HY7B3MN6VWEGSZIXAIUIANJ5ZLR3QN3DFGEAC JWLMAS7UWFXHWBYEBW4YSLPZUF7UFQHAKN3AUF2IAJWVXW46T5PQC impl<'a> From<TextLine<'a>> for Cow<'a, str> {fn from(value: TextLine<'a>) -> Self {value.0.into()}}impl<'a> Display for TextLine<'a> {fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {write!(f, "{}", self.0)}}impl<'a> AsRef<str> for TextLine<'a> {fn as_ref(&self) -> &str {self.0}}
mod server;mod cl {use std::{fmt::Display,io::{stdout, Write},};use clap::Parser;use url::Url;use crate::{danger, media::Media, run, server::TcpNetwork, UI};#[derive(Parser)]pub struct App {#[arg(short, long)]pub test_prompt: bool,pub url: Option<String>,}
mod utils;
pub enum Error {}
pub trait Ui {fn show(&self, content: media::Media);fn warn<D: Display>(&self, warning: D);fn read(&self, prompt: impl Display) -> Option<String>;fn read_secret(&self, prompt: impl Display) -> Option<String>;}impl Ui for () {fn show(&self, _content: media::Media) {}
let secret = cli.read_secret("tell me your secrets");cli.warn(format_args!("your secret was: {secret}"));Ok(())} else {let url = self.url.unwrap_or_else(|| {"gemini://gemini.circumlunar.space/docs/specification.gmi".into()});let url: Url = url.parse().expect("Not a valid URL");run::<TcpNetwork<danger::Naive>>(&mut cli, url);Ok(())}}
fn read(&self, _prompt: impl Display) -> Option<String> {None
pub struct Cli {}impl UI for Cli {fn show(&self, content: Media) {match content {Media::Gemini(g) => println!("{g:#?}"), // TODOMedia::Text(s) => println!("{s}"),}}fn read(&self, prompt: impl Display) -> String {print!("{prompt}: ");stdout().flush().unwrap();std::io::stdin().lines().next().expect("End of input").expect("Failed to read input")}fn read_secret(&self, prompt: impl Display) -> String {let dim = ansi_term::Style::new().dimmed();print!("{prompt}: ");let hidden = "(input hidden)";let len = hidden.len() as u32;let back = ansi_control_codes::control_sequences::CUB(Some(len));print!("{}{back}", dim.paint(hidden));stdout().flush().unwrap();rpassword::read_password().expect("Failed to read input")}
}}fn run<N: Network>(ui: &mut impl UI, mut url: Url)where<N as Network>::Error: std::fmt::Debug,{while let Some(new_url) = run_url::<N>(ui, url) {url = new_url;}}fn run_url<N: Network>(ui: &mut impl UI, url: Url) -> Option<Url>where<N as Network>::Error: std::fmt::Debug,{// must make a new connection per requestlet mut stream = N::connect(&url).expect("Failed to connect");send_request(&mut stream, &url).expect("request failed to send");let head = read_response_header(&mut stream).expect("failed to read header");println!("head: {head:?}");handle_response_header(ui, head, url, stream)}fn handle_response_header(ui: &mut impl UI,head: response::Header,url: Url,stream: impl Read,) -> Option<Url> {match head {response::Header::Input(s) => handle::input(s, ui, url),response::Header::Success(s) => {handle::success(s, stream, ui);None}response::Header::Redirect(s) => Some(handle::redirect(s, ui)),response::Header::FailTemp(s) => {handle::fail_temp(s, ui);None}response::Header::FailPerm(s) => {handle::fail_perm(s, ui);None}response::Header::CertRequired(s) => {handle::cert_required(s, ui);None}}}mod handle {use crate::{decode_media,io::ReadExt,response::header::{CertRequired, FailPerm, FailTemp, Input, Redirect, Success},UI,};use std::io::Read;use url::Url;pub fn cert_required(s: CertRequired, ui: &mut impl UI) {let CertRequired { message, typ } = s;ui.warn(format_args!("Certificate required: {typ:?}! Details:\n{message}"));}pub fn fail_perm(s: FailPerm, ui: &mut impl UI) {let FailPerm { message, typ } = s;ui.warn(format_args!("Permanent failure: {typ:?}! Details:\n{message}"));}pub fn fail_temp(s: FailTemp, ui: &mut impl UI) {let FailTemp { message, typ } = s;ui.warn(format_args!("Temporary failure: {typ:?}! Please try again. Details:\n{message}"));}pub fn redirect(s: Redirect, ui: &mut impl UI) -> Url {let Redirect { url, temporary } = s;if let Some(false) = temporary {ui.warn(format_args!("Server has permanently moved. Redirecting to: {url}\nPlease update your records!"));} else {ui.warn(format_args!("Server has temporarily moved. Redirecting to: {url}"));}url}pub fn success(s: Success, mut stream: impl Read, ui: &mut impl UI) {let Success { mime } = s;let body = stream.read_string().expect("failed to read body");//TODO remove this debug outputprintln!("page is encoded as {mime:?}");println!("response body: {body}");let media = decode_media(mime, &body);ui.show(media);}pub fn input(s: Input, ui: &mut impl UI, mut url: Url) -> Option<Url> {let Input { prompt, sensitive } = s;let input = if let Some(true) = sensitive {ui.read_secret(&prompt)} else {ui.read(&prompt)};let input = urlencoding::encode(&input);url.set_query(Some(&input));Some(url)
}fn read_response_header(mut stream: impl Read) -> ioResult<response::Header> {let status = stream.read_n::<2>()?.map(|s| s.checked_sub(b'0').expect("bad status fragment"));let c = stream.read_byte()?;if c != b' ' {println!("Warning: illegal separator byte '0x{c:02X}' was not a space")}// don't read 1024, but rather read until CRLF// TODO limit to 1024let mut meta = Vec::new();BufReader::new(stream).read_until(b"\r\n", &mut meta)?;// drop last 2for _ in 0..2 {meta.pop();}let meta = response::Meta(meta.into_string().expect("Meta was not valid UTF8"));let status = response::raw::Header { meta, status }.try_into().expect("failed to parse header");Ok(status)}fn send_request(stream: &mut impl Write, url: &Url) -> ioResult<usize> {let mut sent = 0;sent += stream.write(url.as_str().as_bytes())?;sent += stream.write("\r\n".as_bytes())?;Ok(sent)
use crate::{cl::Cli, read_response_header, send_request};use super::response::{header, Header};#[rstest]fn test_handle_redirect(#[values("http://url.com")] expect_url: Url,#[values(None, Some(true), Some(false))] temporary: Option<bool>,) {let head = Header::Redirect(header::Redirect {url: expect_url.clone(),temporary,});let mut app = Cli {};let url = {let crate::response::Header::Redirect(s) = headelse {panic!("expected redirect")};crate::handle::redirect(s, &mut app)};assert_eq!(url, expect_url);}#[test]fn test_request() {let url: Url = "gemini://some/url".try_into().unwrap();let req = b"gemini://some/url\r\n";let mut net_stream = [0; 1024];send_request(&mut net_stream.as_mut_slice(), &url).expect("error sending request");assert_eq!(req, &net_stream[..req.len()]);}
#[case('0', Some(false))]#[case('1', Some(true))]#[case('3', None)]fn test_response_input(#[case] sensitive_code: char, #[case] sensitive: Option<bool>) {let exp_head = Header::Input(header::Input {prompt: "prompt text".to_string(),sensitive,});let resp_text = format!("1{sensitive_code} prompt text\r\n");let resp_head: &[u8] = resp_text.as_bytes();let head = read_response_header(&mut &*resp_head).expect("failed to read response");assert_eq!(exp_head, head);
#[case("gemini://gemini.circumlunar.space")]#[case("gemini://gemini.circumlunar.space/docs/gemtext.gmi")]fn test_connect(#[case] url: Url) {crate::core::run_url::<crate::network::TcpNetwork<crate::danger::Naive>>(&mut crate::cli::Cli {},url,);
use crate::network::Network;use crate::utils::{BufReadExt, ReadExt};use crate::{response, Ui};use bstr::ByteVec;use std::io::{BufReader, Read, Result as ioResult};use url::Url;pub fn run<N: Network>(ui: &mut impl Ui, mut url: Url)where<N as Network>::Error: std::fmt::Debug,{while let Some(new_url) = run_url::<N>(ui, url) {url = new_url;}}pub(crate) fn run_url<N: Network>(ui: &mut impl Ui, url: Url) -> Option<Url>where<N as Network>::Error: std::fmt::Debug,{// must make a new connection per requestlet mut stream = N::connect(&url).expect("Failed to connect");send_request(&mut stream, &url).expect("request failed to send");let head = read_response_header(ui, &mut stream).expect("failed to read header");// println!("head: {head:?}");handle_response_header(ui, head, url, stream)}fn handle_response_header(ui: &mut impl Ui,head: response::Header,url: Url,stream: impl Read,) -> Option<Url> {match head {response::Header::Input(s) => handle::input(s, ui, url),response::Header::Success(s) => {handle::success(s, stream, ui);None}response::Header::Redirect(s) => Some(handle::redirect(s, ui)),response::Header::FailTemp(s) => {handle::fail_temp(s, ui);None}response::Header::FailPerm(s) => {handle::fail_perm(s, ui);None}response::Header::CertRequired(s) => {handle::cert_required(s, ui);None}}}pub mod handle {use crate::{decode_media,response::header::{CertRequired, FailPerm, FailTemp, Input, Redirect, Success},utils::ReadExt,Ui,};use std::io::Read;use url::Url;pub fn cert_required(s: CertRequired, ui: &mut impl Ui) {let CertRequired { message, typ } = s;ui.warn(format_args!("Certificate required: {typ:?}! Details:\n{message}"));}pub fn fail_perm(s: FailPerm, ui: &mut impl Ui) {let FailPerm { message, typ } = s;ui.warn(format_args!("Permanent failure: {typ:?}! Details:\n{message}"));}pub fn fail_temp(s: FailTemp, ui: &mut impl Ui) {let FailTemp { message, typ } = s;ui.warn(format_args!("Temporary failure: {typ:?}! Please try again. Details:\n{message}"));}pub fn redirect(s: Redirect, ui: &mut impl Ui) -> Url {let Redirect { url, temporary } = s;if let Some(false) = temporary {ui.warn(format_args!("Server has permanently moved. Redirecting to: {url}\nPlease update your records!"));} else {ui.warn(format_args!("Server has temporarily moved. Redirecting to: {url}"));}url}pub fn success(s: Success, mut stream: impl Read, ui: &mut impl Ui) {let Success { mime } = s;let body = stream.read_string().expect("failed to read body");//TODO remove this debug output// println!("page is encoded as {mime:?}");// println!("response body: {body}");let media = decode_media(mime, &body);ui.show(media);}pub fn input(s: Input, ui: &mut impl Ui, mut url: Url) -> Option<Url> {let Input { prompt, sensitive } = s;let input = if let Some(true) = sensitive {ui.read_secret(&prompt)} else {ui.read(&prompt)}.unwrap();let input = urlencoding::encode(&input);url.set_query(Some(&input));Some(url)}}pub fn read_response_header(ui: &impl Ui, mut stream: impl Read) -> ioResult<response::Header> {let status = stream.read_n::<2>()?;let c = stream.read_byte()?;if c != b' ' {ui.warn(format_args!("illegal separator byte '0x{c:02X}' was not a space"));}// don't read 1024, but rather read until CRLF// TODO limit to 1024let mut meta = Vec::new();BufReader::new(stream).read_until(b"\r\n", &mut meta)?;// println!(// "status line: \"{}{}{}\"",// std::str::from_utf8(&status).unwrap(),// std::str::from_utf8(&[c]).unwrap(),// std::str::from_utf8(&*meta).unwrap()// );// turn status from ascii to numericlet status = status.map(|s| s.checked_sub(b'0').expect("bad status fragment"));// drop last 2for _ in 0..2 {meta.pop();}let meta = response::Meta(meta.into_string().expect("Meta was not valid UTF8"));let status = response::raw::Header { meta, status }.try_into().expect("failed to parse header");Ok(status)}pub fn send_request(stream: &mut impl std::io::Write, url: &Url) -> ioResult<usize> {let mut sent = 0;sent += stream.write(url.as_str().as_bytes())?;sent += stream.write("\r\n".as_bytes())?;Ok(sent)}#[cfg(test)]mod tests {use rstest::*;use url::Url;use super::{read_response_header, send_request};use crate::response::{header, Header};#[rstest]fn test_handle_redirect(#[values("http://url.com")] expect_url: Url,#[values(None, Some(true), Some(false))] temporary: Option<bool>,) {let head = Header::Redirect(header::Redirect {url: expect_url.clone(),temporary,});let mut app = crate::cli::Cli {};let url = {let Header::Redirect(s) = headelse {panic!("expected redirect")};crate::core::handle::redirect(s, &mut app)};assert_eq!(url, expect_url);}#[rstest]#[case('0', Some(false))]#[case('1', Some(true))]#[case('3', None)]fn test_response_input(#[case] sensitive_code: char, #[case] sensitive: Option<bool>) {let exp_head = Header::Input(header::Input {prompt: "prompt text".to_string(),sensitive,});let resp_text = format!("1{sensitive_code} prompt text\r\n");let resp_head: &[u8] = resp_text.as_bytes();let head = read_response_header(&(), &mut &*resp_head).expect("failed to read response");assert_eq!(exp_head, head);}#[test]fn test_request() {let url: Url = "gemini://some/url".try_into().unwrap();let req = b"gemini://some/url\r\n";let mut net_stream = [0; 1024];send_request(&mut net_stream.as_mut_slice(), &url).expect("error sending request");assert_eq!(req, &net_stream[..req.len()]);}}
use std::{fmt::Display,io::{stdout, Write},};use ansi_term::{Colour, Style};use clap::Parser;use url::Url;use crate::{core::run,danger,media::{Gemini, Line, Media, Preformat},network::TcpNetwork,Ui,};#[derive(Parser)]pub struct App {#[arg(short, long)]pub test_mode: bool,pub url: Option<String>,}// TODO proper error handlingpub enum Error {}impl App {pub fn run(self) -> Result<(), Error> {let mut cli = Cli {};if self.test_mode {test_run(&cli);Ok(())} else {let url = self.url.unwrap_or_else(|| {"gemini://gemini.circumlunar.space/docs/specification.gmi".into()});let url: Url = url.parse().expect("Not a valid URL");run::<TcpNetwork<danger::Naive>>(&mut cli, url);Ok(())}}}fn test_run(cli: &Cli) {let text: Gemini = "plain line# heading1## heading 2### heading 3> quote 1> quote 2> quote 3lineline 2line 3* list 1* list 2* list 3=> gemini://url?query description```rssome rust code herematch true {true=> false,false=> true,}```# heading> quoteline* list=> gemini://url descriptionordinary line".into();let secret = cli.read_secret("tell me your secrets").unwrap();cli.warn(format_args!("your secret was: {secret}"));let overt = cli.read("tell me something that isn't secret, since I can't be trusted with secrets").unwrap();cli.warn(format_args!("your response was: {overt}"));cli.render_gemini(text);}pub struct Cli {}impl Cli {fn render_gemini(&self, g: Gemini) {// println!("{:#?}", g)let Gemini { lines } = g;for line in lines {match line {Line::Heading { level, title } => {let title = title.as_ref();let char = match level {crate::media::HeadingLevel::L1 => '=',crate::media::HeadingLevel::L2 => '-',crate::media::HeadingLevel::L3 => ' ',};let under = core::iter::repeat(char).take(title.len()).collect::<String>();let style = ansi_term::Style::new().bold();println!();println!("{}", style.paint(&under));println!("{}", style.paint(title));println!("{}", style.paint(&under));}Line::Link { url, description } => {let blue = Colour::Cyan;print!("{}: {}", '🔗', blue.paint(url));if let Some(description) = description {let italic = ansi_term::Style::new().italic();println!(" | {}", italic.paint(description));}}Line::Text(t) => println!("{}", t),Line::Preformatted(p) => {let Preformat { alt, lines } = p;let dim = Style::new().dimmed().italic();let bg = Style::new().on(Colour::Black);println!("{}", dim.paint(alt));for line in lines {println!("{}", bg.paint(line));}println!();}Line::ListItem(li) => {println!(" • {}", li)}Line::Quote(q) => {let italic = Style::new().italic();println!(" | {}", italic.paint(q));}}}}}impl Ui for Cli {fn show(&self, content: Media) {match content {Media::Gemini(g) => self.render_gemini(g),Media::Text(s) => println!("{s}"),}}fn read(&self, prompt: impl Display) -> Option<String> {print!("{prompt}: ");stdout().flush().unwrap();Some(std::io::stdin().lines().next()?.expect("Failed to read input"),)}fn read_secret(&self, prompt: impl Display) -> Option<String> {let dim = ansi_term::Style::new().dimmed();print!("{prompt}: ");let hidden = "(input hidden)";let len = hidden.len() as u32;let back = ansi_control_codes::control_sequences::CUB(Some(len));print!("{}{back}", dim.paint(hidden));stdout().flush().unwrap();match rpassword::read_password() {Ok(v) => Some(v),Err(e) => match e.kind() {std::io::ErrorKind::UnexpectedEof => None,_ => panic!("Failed to read input"),},}}fn warn<D: std::fmt::Display>(&self, warning: D) {let red = Colour::Red;println!("{}: {}", red.paint("Warning"), warning)}}