~boringcactus/gemifedi

d43bab6770ba7a4639549828c75a9de66f62b067 — Melody Horn 1 year, 1 month ago 6c44113
make some things not suck
A .build.yml => .build.yml +12 -0
@@ 0,0 1,12 @@
image: alpine/latest
packages:
  - rust
  - cargo
sources:
  - https://git.sr.ht/~boringcactus/gemifedi
tasks:
  - build: |
      cd gemifedi
      cargo build
artifacts:
  - gemifedi/target/release/gemifedi

M Cargo.lock => Cargo.lock +197 -8
@@ 720,6 720,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7"

[[package]]
name = "futf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c9c1ce3fa9336301af935ab852c437817d14cd33690446569392e65170aac3b"
dependencies = [
 "mac",
 "new_debug_unreachable",
]

[[package]]
name = "futures"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 852,12 862,18 @@ dependencies = [
 "async-std",
 "async-trait",
 "elefren",
 "html5ever",
 "log",
 "maj",
 "markup5ever",
 "markup5ever_rcdom",
 "native-tls",
 "pretty_env_logger",
 "rustls",
 "serde",
 "sha2",
 "structopt",
 "tempfile",
 "toml",
 "url 2.1.1",
 "webpki",


@@ 966,6 982,20 @@ dependencies = [
]

[[package]]
name = "html5ever"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aafcf38a1a36118242d29b92e1b08ef84e67e4a5ed06e0a80be20e6a32bfed6b"
dependencies = [
 "log",
 "mac",
 "markup5ever",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "http"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1130,8 1160,8 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "265ef164908329e47e753c769b14cbb27434abf0c41984dca201484022f09ce5"
dependencies = [
 "phf",
 "phf_codegen",
 "phf 0.7.24",
 "phf_codegen 0.7.24",
 "serde",
]



@@ 1206,6 1236,12 @@ dependencies = [
]

[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"

[[package]]
name = "maj"
version = "0.6.0"
source = "git+https://tulpa.dev/boringcactus/maj.git?branch=preserve-client-certs#290c2626d1cadf66b9bb6ca2fa75dc00014dd5f1"


@@ 1231,6 1267,35 @@ dependencies = [
]

[[package]]
name = "markup5ever"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aae38d669396ca9b707bfc3db254bc382ddb94f57cc5c235f34623a669a01dab"
dependencies = [
 "log",
 "phf 0.8.0",
 "phf_codegen 0.8.0",
 "serde",
 "serde_derive",
 "serde_json",
 "string_cache",
 "string_cache_codegen",
 "tendril",
]

[[package]]
name = "markup5ever_rcdom"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f015da43bcd8d4f144559a3423f4591d69b8ce0652c905374da7205df336ae2b"
dependencies = [
 "html5ever",
 "markup5ever",
 "tendril",
 "xml5ever",
]

[[package]]
name = "matches"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1387,6 1452,12 @@ dependencies = [
]

[[package]]
name = "new_debug_unreachable"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54"

[[package]]
name = "num"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1529,6 1600,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de"

[[package]]
name = "openssl-src"
version = "111.11.0+1.1.1h"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "380fe324132bea01f45239fadfec9343adb044615f29930d039bec1ae7b9fa5b"
dependencies = [
 "cc",
]

[[package]]
name = "openssl-sys"
version = "0.9.58"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1537,6 1617,7 @@ dependencies = [
 "autocfg 1.0.1",
 "cc",
 "libc",
 "openssl-src",
 "pkg-config",
 "vcpkg",
]


@@ 1591,7 1672,16 @@ version = "0.7.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3da44b85f8e8dfaec21adae67f95d93244b2ecf6ad2a692320598dcc8e6dd18"
dependencies = [
 "phf_shared",
 "phf_shared 0.7.24",
]

[[package]]
name = "phf"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
dependencies = [
 "phf_shared 0.8.0",
]

[[package]]


@@ 1600,8 1690,18 @@ version = "0.7.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b03e85129e324ad4166b06b2c7491ae27fe3ec353af72e72cd1654c7225d517e"
dependencies = [
 "phf_generator",
 "phf_shared",
 "phf_generator 0.7.24",
 "phf_shared 0.7.24",
]

[[package]]
name = "phf_codegen"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815"
dependencies = [
 "phf_generator 0.8.0",
 "phf_shared 0.8.0",
]

[[package]]


@@ 1610,17 1710,36 @@ version = "0.7.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09364cc93c159b8b06b1f4dd8a4398984503483891b0c26b867cf431fb132662"
dependencies = [
 "phf_shared",
 "phf_shared 0.7.24",
 "rand 0.6.5",
]

[[package]]
name = "phf_generator"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526"
dependencies = [
 "phf_shared 0.8.0",
 "rand 0.7.3",
]

[[package]]
name = "phf_shared"
version = "0.7.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234f71a15de2288bcb7e3b6515828d22af7ec8598ee6d24c3b526fa0a80b67a0"
dependencies = [
 "siphasher",
 "siphasher 0.2.3",
]

[[package]]
name = "phf_shared"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7"
dependencies = [
 "siphasher 0.3.3",
]

[[package]]


@@ 1681,6 1800,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c36fa947111f5c62a733b652544dd0016a43ce89619538a8ef92724a6f501a20"

[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"

[[package]]
name = "pretty_env_logger"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 1799,7 1924,7 @@ dependencies = [
 "rand_isaac",
 "rand_jitter",
 "rand_os",
 "rand_pcg",
 "rand_pcg 0.1.2",
 "rand_xorshift",
 "winapi 0.3.9",
]


@@ 1815,6 1940,7 @@ dependencies = [
 "rand_chacha 0.2.2",
 "rand_core 0.5.1",
 "rand_hc 0.2.0",
 "rand_pcg 0.2.1",
]

[[package]]


@@ 1924,6 2050,15 @@ dependencies = [
]

[[package]]
name = "rand_pcg"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429"
dependencies = [
 "rand_core 0.5.1",
]

[[package]]
name = "rand_xorshift"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 2246,6 2381,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b8de496cf83d4ed58b6be86c3a275b8602f6ffe98d3024a869e124147a9a3ac"

[[package]]
name = "siphasher"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa8f3741c7372e75519bd9346068370c9cdaabcc1f9599cbcf2a2719352286b7"

[[package]]
name = "skeptic"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 2304,6 2445,31 @@ dependencies = [
]

[[package]]
name = "string_cache"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2940c75beb4e3bf3a494cef919a747a2cb81e52571e212bfbd185074add7208a"
dependencies = [
 "lazy_static",
 "new_debug_unreachable",
 "phf_shared 0.8.0",
 "precomputed-hash",
 "serde",
]

[[package]]
name = "string_cache_codegen"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f24c8e5e19d22a726626f1a5e16fe15b132dcf21d10177fa5a45ce7962996b97"
dependencies = [
 "phf_generator 0.8.0",
 "phf_shared 0.8.0",
 "proc-macro2",
 "quote",
]

[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 2387,6 2553,17 @@ dependencies = [
]

[[package]]
name = "tendril"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707feda9f2582d5d680d733e38755547a3e8fb471e7ba11452ecfd9ce93a5d3b"
dependencies = [
 "futf",
 "mac",
 "utf-8",
]

[[package]]
name = "termcolor"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"


@@ 2997,3 3174,15 @@ dependencies = [
 "winapi 0.2.8",
 "winapi-build",
]

[[package]]
name = "xml5ever"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1b52e6e8614d4a58b8e70cf51ec0cc21b256ad8206708bcff8139b5bbd6a59"
dependencies = [
 "log",
 "mac",
 "markup5ever",
 "time",
]

M Cargo.toml => Cargo.toml +8 -1
@@ 2,6 2,7 @@
name = "gemifedi"
version = "0.1.0"
authors = ["Melody Horn <melody@boringcactus.com>"]
description = "a gemini frontend to the fediverse"
edition = "2018"
license = "AGPL-3.0-or-later"



@@ 9,12 10,18 @@ license = "AGPL-3.0-or-later"
async-std = { version = "1.6.4", features = ["attributes"] }
async-trait = "0.1.40"
elefren = { version = "0.22", features = ["toml"] }
html5ever = "0.25.1"
log = "0.4.11"
maj = { version = "0.6.0", path = "../misc/maj", git = "https://tulpa.dev/boringcactus/maj.git", branch = "preserve-client-certs" }
maj = { version = "0.6.0", git = "https://tulpa.dev/boringcactus/maj.git", branch = "preserve-client-certs" }
markup5ever = "0.10.0"
markup5ever_rcdom = "0.1.0"
native-tls = { version = "0.2.4", features = ["vendored"] }
pretty_env_logger = "0.4.0"
rustls = "0.18.1"
serde = { version = "1.0.116", features = ["derive"] }
sha2 = "0.9.1"
structopt = "0.3.18"
tempfile = "3"
toml = "0.5.6"
url = "2.1.1"
webpki = "0.21.3"

M src/client_cert_fix.rs => src/client_cert_fix.rs +0 -2
@@ 14,12 14,10 @@ impl TrustAnyClientCertOrAnonymous {

impl rustls::ClientCertVerifier for TrustAnyClientCertOrAnonymous {
    fn offer_client_auth(&self) -> bool {
        log::debug!("yep we offer client auth");
        true
    }

    fn client_auth_mandatory(&self, _sni: Option<&DNSName>) -> Option<bool> {
        log::debug!("nope client auth isn't mandatory");
        Some(false)
    }


A src/gemini_util.rs => src/gemini_util.rs +43 -0
@@ 0,0 1,43 @@
use maj::{
    gemini as gemtext,
    Response as GeminiResponse,
    server::{
        Request as GeminiRequest,
    },
};

pub trait RequestExt {
    fn cert_hash(&self) -> Option<String>;
}

fn cert_hash(certificate: &rustls::Certificate) -> String {
    use sha2::Digest;
    format!("{:x}", sha2::Sha256::digest(certificate.as_ref()))
}

impl RequestExt for GeminiRequest {
    fn cert_hash(&self) -> Option<String> {
        match &self.certs {
            Some(cert) => Some(cert_hash(&cert[0])),
            None => None,
        }
    }
}

pub fn strip_html(html: &str) -> Vec<gemtext::Node> {
    crate::html2gemtext::parse_html(html)
}

pub fn temp_redirect<I: Into<String>>(dest: I) -> GeminiResponse {
    let mut redirect = GeminiResponse::perm_redirect(dest.into());
    redirect.status = maj::StatusCode::TemporaryRedirect;
    redirect
}

pub fn apply_global_template(nodes: Vec<gemtext::Node>) -> Vec<gemtext::Node> {
    let mut result = vec![
    ];
    result.extend(nodes);
    result.push(gemtext::Node::Link { to: "/about".to_string(), name: Some(format!("powered by gemifedi"))});
    result
}

A src/html2gemtext.rs => src/html2gemtext.rs +152 -0
@@ 0,0 1,152 @@
// shout out to https://gitlab.com/Kanedias/html2md

use html5ever::{
    tendril::TendrilSink,
    QualName,
};
use maj::{
    gemini as gemtext,
};
use markup5ever::{namespace_url, ns};
use markup5ever_rcdom::{Handle, NodeData, RcDom};

pub fn parse_html(html: &str) -> Vec<gemtext::Node> {
    let context = QualName::new(
        None,
        ns!(html),
        markup5ever::LocalName::from("div"),
    );
    let parser = html5ever::parse_fragment(
        RcDom::default(),
        html5ever::ParseOpts::default(),
        context,
        vec![],
    );
    let dom = parser.one(html);

    let mut result = ParseState::default();
    walk(&dom.document, &mut result);

    result.finished
}

fn walk(input: &Handle, result: &mut ParseState) {
    let mut new_context = None;
    let mut was_tag = false;
    match input.data {
        NodeData::Document | NodeData::Doctype {..} | NodeData::ProcessingInstruction {..} | NodeData::Comment {..} => {},
        NodeData::Text { ref contents }  => {
            let text = contents.borrow().to_string();
            result.pending += &text;
        }
        NodeData::Element { ref name, ref attrs, .. } => {
            was_tag = true;
            let tag_name = name.local.to_string();
            // no user-supplied factory, take one of built-in ones
            new_context = match tag_name.as_ref() {
                "p" => Some(Context::Paragraph),
                "br" => {
                    result.finish();
                    Some(Context::Paragraph)
                }
                "blockquote" => Some(Context::Quote),
                "h1" => Some(Context::Header(1)),
                "h2" => Some(Context::Header(2)),
                "h3" => Some(Context::Header(3)),
                "h4" => Some(Context::Header(4)),
                "h5" => Some(Context::Header(5)),
                "h6" => Some(Context::Header(6)),
                "pre" => Some(Context::Preformatted),
                "a" => {
                    result.finish();
                    Some(Context::Link(attrs.borrow().iter().find_map(|attribute| {
                        if attribute.name.local == *"href" {
                            Some(attribute.value.to_string())
                        } else {
                            None
                        }
                    }).unwrap_or("".to_string())))
                },
                "li" => Some(Context::ListItem),
                "span" => Some(Context::FlattenedOut),
                "html" => Some(Context::Paragraph),
                tag => {
                    log::info!("unknown tag <{}>", tag);
                    Some(Context::FlattenedOut)
                }
            };
            if let Some(new_context) = &new_context {
                result.context.push(new_context.clone());
            }
        }
    }

    for child in input.children.borrow().iter() {
        use std::borrow::Borrow;
        walk(child.borrow(), result);
    }

    if was_tag {
        result.finish();
    }
    if new_context.take().is_some() {
        result.context.pop();
    }
}

#[derive(Default)]
struct ParseState {
    finished: Vec<gemtext::Node>,
    pending: String,
    context: Vec<Context>,
}

impl ParseState {
    fn finish(&mut self) {
        if !self.pending.is_empty() && self.context.last().map_or(true, |c| c != &Context::FlattenedOut) {
            let finished = std::mem::replace(&mut self.pending, String::new());
            let context = self.context.last().unwrap_or(&Context::Paragraph);
            self.finished.push(context.apply(finished));
        }
    }
}

#[derive(Clone, PartialEq)]
enum Context {
    Paragraph,
    Quote,
    Header(u8),
    Preformatted,
    Link(String),
    ListItem,
    FlattenedOut,
}

impl Context {
    fn apply(&self, contents: String) -> gemtext::Node {
        match self {
            Context::Paragraph => gemtext::Node::Text(contents),
            Context::Quote => gemtext::Node::Quote(contents),
            Context::Header(level) => gemtext::Node::Heading { level: *level, body: contents },
            Context::Preformatted => gemtext::Node::Preformatted(contents),
            Context::Link(href) => gemtext::Node::Link { to: href.clone(), name: Some(contents) },
            Context::ListItem => gemtext::Node::ListItem(contents),
            Context::FlattenedOut => gemtext::Node::Preformatted("this should not happen!".to_string()),
        }
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_thing_that_keeps_not_working() {
        let broken_example = r#"<p><span><a href="a">@<span>a</span></a></span> hi</p>"#;
        let expected = gemtext::Builder::new()
            .link("a", Some("@a".to_string()))
            .text(" hi")
            .build();
        assert_eq!(expected, parse_html(broken_example));
    }
}

M src/main.rs => src/main.rs +189 -76
@@ 1,5 1,5 @@
use std::{
    fs::{self, File, create_dir_all},
    fs::{File, create_dir_all},
    io::BufReader,
    path::PathBuf,
    sync::Arc,


@@ 8,7 8,6 @@ use std::{
use elefren::{
    prelude::*,
    helpers::toml as mastodon_toml,
    registration::Registered,
};
use maj::{
    gemini as gemtext,


@@ 21,71 20,67 @@ use maj::{
    route, split, seg,
};
use rustls::ServerConfig;
use serde::{Serialize, Deserialize};

mod client_cert_fix;
use client_cert_fix::TrustAnyClientCertOrAnonymous;

mod html2gemtext;

mod gemini_util;
use gemini_util::*;
mod mastodon_util;
use mastodon_util::*;

fn render_post(post: elefren::entities::status::Status) -> Vec<gemtext::Node> {
    gemtext::Builder::new()
        .text(post.account.display_name)
        .quote(post.content)
        .build()
}
    let reply_info = match post.in_reply_to_account_id {
        Some(account) => {
            let replyee = if account == post.account.id {
                post.account.display_name.clone()
            } else {
                post.mentions.iter()
                    .find_map(|mention| {
                        if mention.id == account {
                            Some(mention.acct.clone())
                        } else {
                            None
                        }
                    })
                    .unwrap_or("someone they untagged".to_string())
            };
            format!(", replying to {}", replyee)
        },
        None => format!("")
    };

fn cert_hash(certificate: &rustls::Certificate) -> String {
    use sha2::Digest;
    format!("{:x}", sha2::Sha256::digest(certificate.as_ref()))
}
    let reblog_info = match post.reblog {
        Some(status) => format!(", reblogging {}", status.account.display_name),
        None => format!("")
    };

fn cert_hash_for(req: &GeminiRequest) -> String {
    match &req.certs {
        Some(cert) => cert_hash(&cert[0]),
        None => "no-cert-found".to_string(),
    }
    let post_info = format!(
        "{account_name}{reply_info}{reblog_info}:",
        account_name=post.account.display_name,
        reply_info=reply_info,
        reblog_info=reblog_info,
    );

    let mut result = vec![
        gemtext::Node::Heading { level: 2, body: post_info },
    ];
    result.extend(strip_html(&post.content));
    result.extend(vec![
        gemtext::Node::Link { to: format!("/status/{}", post.id), name: None },
        gemtext::Node::blank(),
    ]);
    result
}

fn mastodon_data_path(req: &GeminiRequest) -> PathBuf {
fn mastodon_data_path(req: &GeminiRequest) -> Option<PathBuf> {
    let mut result = PathBuf::from("data");
    result.push("users");
    let hash = cert_hash_for(req);
    let hash = req.cert_hash()?;
    result.push(hash);
    result.with_extension("toml")
}

fn temp_redirect<I: Into<String>>(dest: I) -> GeminiResponse {
    let mut redirect = GeminiResponse::perm_redirect(dest.into());
    redirect.status = maj::StatusCode::TemporaryRedirect;
    redirect
}

#[derive(Serialize, Deserialize)]
struct AppInstall {
    parts: (String, String, String, String, Scopes, bool),
}

fn get_app_install(domain: &str) -> Registered<elefren::http_send::HttpSender> {
    let mut data_path = PathBuf::from("data");
    data_path.push("domains");
    create_dir_all(&data_path).unwrap();
    data_path.push(format!("{}.toml", domain));

    if data_path.exists() {
        let file_data = fs::read(data_path).unwrap();
        let install: AppInstall = toml::from_slice(file_data.as_slice()).unwrap();
        let (base, client_id, client_secret, redirect, scopes, force_login) = install.parts;
        Registered::from_parts(&base, &client_id, &client_secret, &redirect, scopes, force_login)
    } else {
        let registered = Registration::new(format!("https://{}", domain))
            .client_name("gemifedi")
            .build()
            .unwrap();
        let parts = registered.clone().into_parts();
        let install = AppInstall { parts };
        let file_data = toml::to_string(&install).unwrap();
        fs::write(data_path, file_data).unwrap();
        registered
    }
    Some(result.with_extension("toml"))
}

struct Handler;


@@ 98,10 93,14 @@ impl Default for Handler {

impl Handler {
    fn home(&self, req: GeminiRequest) -> Result<GeminiResponse, Error> {
        let mastodon = if let Ok(data) = mastodon_toml::from_file(mastodon_data_path(&req)) {
            Mastodon::from(data)
        } else {
            return Ok(temp_redirect("/auth"));
        let path = mastodon_data_path(&req);
        let path = match path {
            Some(path) => path,
            None => return Ok(GeminiResponse::need_cert("Need a cert to be able to log in!"))
        };
        let mastodon = match mastodon_toml::from_file(path) {
            Ok(data) => Mastodon::from(data),
            Err(_) => return Ok(temp_redirect("/auth"))
        };

        let home = mastodon.get_home_timeline().unwrap();


@@ 111,7 110,7 @@ impl Handler {
            .flat_map(render_post)
            .collect::<Vec<_>>();

        Ok(GeminiResponse::render(posts))
        Ok(GeminiResponse::render(apply_global_template(posts)))
    }

    fn auth_begin(&self, req: GeminiRequest) -> Result<GeminiResponse, Error> {


@@ 153,49 152,163 @@ impl Handler {
        let mastodon = app_install.complete(code).unwrap();

        // Save app data for using on the next run.
        let data_path = mastodon_data_path(&req);
        data_path.parent().and_then(|parent| create_dir_all(parent).ok());
        mastodon_toml::to_file(&*mastodon, mastodon_data_path(&req)).unwrap();
        let path = mastodon_data_path(&req);
        let path = match path {
            Some(path) => path,
            None => return Ok(GeminiResponse::need_cert("Need a cert to be able to log in!"))
        };
        path.parent().and_then(|parent| create_dir_all(parent).ok());
        mastodon_toml::to_file(&*mastodon, path).unwrap();

        let mut destination = req.url.clone();
        destination.path_segments_mut().unwrap().clear();
        destination.set_query(None);
        Ok(temp_redirect(destination.to_string()))
    }

    fn status(&self, req: GeminiRequest, id: &str) -> Result<GeminiResponse, Error> {
        let path = mastodon_data_path(&req);
        let path = match path {
            Some(path) => path,
            None => return Ok(GeminiResponse::need_cert("Need a cert to be able to log in!"))
        };
        let mastodon = match mastodon_toml::from_file(path) {
            Ok(data) => Mastodon::from(data),
            Err(_) => return Ok(temp_redirect("/auth"))
        };

        Ok(match mastodon.get_status(id) {
            Ok(status) => GeminiResponse::render(apply_global_template(render_post(status))),
            Err(_) => GeminiResponse::not_found(),
        })
    }

    fn about(&self, _req: GeminiRequest) -> Result<GeminiResponse, Error> {
        let response = gemtext::parse(r#"# About gemifedi

gemifedi is a Mastodon / Pleroma client for the Gemini protocol. it uses the OAuth API to access your timeline, and stores data keyed by your chosen client certificate. i recommend hosting it yourself instead of trusting someone else to host it for you.

=> https://git.sr.ht/~boringcactus/gemifedi source code and hosting instructions
=> /auth                                    log in on this install of gemifedi
"#);

        Ok(GeminiResponse::render(response))
    }
}

#[async_trait::async_trait]
impl GeminiHandler for Handler {
    async fn handle(&self, req: GeminiRequest) -> Result<GeminiResponse, Error> {
        println!("got a request {}", req.url);
        if req.certs.as_ref().map_or(true, |certs| certs.is_empty()) {
            let mut response = GeminiResponse::gemini(b"Authentication required".to_vec());
            response.status = maj::StatusCode::ClientCertificateRequired;
            return Ok(response);
        }
        let path = req.url.path().to_string();
        route!(path, {
            (/)                         => self.home(req);
            (/"auth")                   => self.auth_begin(req);
            (/"auth"/[instance])        => self.auth_continue(req, instance);
            (/"auth"/[instance]/"code") => self.auth_finish(req, instance);
            (/"status"/[id])            => self.status(req, id);
            (/"about")                  => self.about(req);
        });
        Ok(GeminiResponse::not_found())
    }
}

use structopt::StructOpt;

#[derive(StructOpt, Debug)]
#[structopt(author, about)]
struct Opt {
    /// Path to TLS certificate (will attempt to generate if not found, defaults to ./<domain>.cert)
    #[structopt(short, long, parse(from_os_str))]
    cert: Option<PathBuf>,

    /// Path to TLS key (will attempt to generate if not found, defaults to ./<domain>.key)
    #[structopt(short, long, parse(from_os_str))]
    key: Option<PathBuf>,

    /// Domain name (e.g. `localhost`, `gemifedi.example.net`)
    #[structopt()]
    domain: String,

    /// Port number to run on
    #[structopt(default_value = "1965")]
    port: u16,
}

#[async_std::main]
async fn main() -> Result<(), Error> {
    pretty_env_logger::formatted_builder()
        .filter(Some("rustls"), log::LevelFilter::Debug)
        .filter(Some("maj"), log::LevelFilter::max())
        .filter(None, log::LevelFilter::Info)
        .filter(Some("gemifedi"), log::LevelFilter::max())
        .filter(Some("reqwest"), log::LevelFilter::max())
        .init();

    let options: Opt = Opt::from_args();

    if let Err(error) = url::Host::parse(&options.domain) {
        eprintln!("{:?} is not a valid hostname", options.domain);
        return Err(Box::new(error));
    }

    let key_file = options.key.unwrap_or(PathBuf::from(format!("./{}.key", options.domain)));

    if !key_file.exists() {
        log::info!("couldn't find key file {}, generating", key_file.display());
        use std::process::Command;
        let genrsa = Command::new("openssl")
            .args(&["genrsa", "-out"])
            .arg(&key_file)
            .arg("2048")
            .output();
        match genrsa {
            Err(err) => {
                eprintln!("Failed to generate key file");
                return Err(Box::new(err));
            }
            Ok(output) if !output.status.success() => {
                eprintln!("Error generating key file");
                return Err(format!("{:#?}", output).into());
            }
            _ => {
                log::debug!("generated key file OK")
            }
        }
    }

    let cert_file = options.cert.unwrap_or(PathBuf::from(format!("./{}.cert", options.domain)));

    if !cert_file.exists() {
        log::info!("couldn't find certificate file {}, generating", cert_file.display());
        let mut temp_file = tempfile::NamedTempFile::new()?;
        use std::io::Write;
        writeln!(temp_file, "[req]\ndistinguished_name=req\n[SAN]\nsubjectAltName=DNS:{}", options.domain)?;
        use std::process::Command;
        let req = Command::new("openssl")
            .args(&["req", "-new", "-x509", "-key"])
            .arg(&key_file)
            .arg("-out")
            .arg(&cert_file)
            .args(&["-days", "3650", "-subj"])
            .arg(format!("/CN={}", options.domain))
            .args(&["-extensions", "SAN", "-config"])
            .arg(temp_file.path())
            .output();
        match req {
            Err(err) => {
                eprintln!("Failed to generate certificate file");
                return Err(Box::new(err));
            }
            Ok(output) if !output.status.success() => {
                eprintln!("Error generating certificate file");
                return Err(format!("{:#?}", output).into());
            }
            _ => {
                log::debug!("generated certificate file OK")
            }
        }
    }

    let mut tls_config = ServerConfig::new(TrustAnyClientCertOrAnonymous::new());
    let mut cert_file = BufReader::new(File::open("localhost.cert")?);
    let mut key_file = BufReader::new(File::open("localhost.key")?);
    let mut cert_file = BufReader::new(File::open(cert_file)?);
    let mut key_file = BufReader::new(File::open(key_file)?);
    let keys = rustls::internal::pemfile::rsa_private_keys(&mut key_file).unwrap();
    let key = keys.into_iter().next().unwrap();
    tls_config.set_single_cert(


@@ 206,7 319,7 @@ async fn main() -> Result<(), Error> {
    maj::server::serve(
        Arc::new(Handler::default()),
        tls_config,
        "localhost".to_string(),
        49302,
        options.domain,
        options.port,
    ).await
}

A src/mastodon_util.rs => src/mastodon_util.rs +40 -0
@@ 0,0 1,40 @@
use std::{
    fs::{self, create_dir_all},
    path::PathBuf,
};

use elefren::{
    prelude::*,
    registration::Registered,
};
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct AppInstall {
    parts: (String, String, String, String, Scopes, bool),
}

pub fn get_app_install(domain: &str) -> Registered<elefren::http_send::HttpSender> {
    let mut data_path = PathBuf::from("data");
    data_path.push("domains");
    create_dir_all(&data_path).unwrap();
    data_path.push(format!("{}.toml", domain));

    if data_path.exists() {
        let file_data = fs::read(data_path).unwrap();
        let install: AppInstall = toml::from_slice(file_data.as_slice()).unwrap();
        let (base, client_id, client_secret, redirect, scopes, force_login) = install.parts;
        Registered::from_parts(&base, &client_id, &client_secret, &redirect, scopes, force_login)
    } else {
        let registered = Registration::new(format!("https://{}", domain))
            .client_name("gemifedi")
            .website("https://git.sr.ht/~boringcactus/gemifedi")
            .build()
            .unwrap();
        let parts = registered.clone().into_parts();
        let install = AppInstall { parts };
        let file_data = toml::to_string(&install).unwrap();
        fs::write(data_path, file_data).unwrap();
        registered
    }
}