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:#?}"), // TODO
Media::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 request
let 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 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)
};
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 1024
let mut meta = Vec::new();
BufReader::new(stream).read_until(b"\r\n", &mut meta)?;
// drop last 2
for _ 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) = head
else {
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 request
let 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 1024
let 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 numeric
let status = status.map(|s| s.checked_sub(b'0').expect("bad status fragment"));
// drop last 2
for _ 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) = head
else {
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 handling
pub 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 3
line
line 2
line 3
* list 1
* list 2
* list 3
=> gemini://url?query description
```rs
some rust code here
match true {
true
=> false,
false
=> true,
}
```
# heading
> quote
line
* list
=> gemini://url description
ordinary 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)
}
}