use std::{
fs::{self, File, create_dir_all},
iter,
io::BufReader,
path::PathBuf,
sync::Arc,
};
use elefren::{
prelude::*,
helpers::toml as mastodon_toml,
};
use maj::{
gemini as gemtext,
Response as GeminiResponse,
server::{
Handler as GeminiHandler,
Request as GeminiRequest,
Error,
},
route, split, seg,
};
use rustls::ServerConfig;
mod client_cert_fix;
use client_cert_fix::TrustAnyClientCertOrAnonymous;
mod html2gemtext;
mod gemini_util;
use gemini_util::*;
mod mastodon_util;
use mastodon_util::*;
fn undo_prefix(did: Option<bool>) -> &'static str {
if did.unwrap_or(false) {
"un"
} else {
""
}
}
fn render_post(post: elefren::entities::status::Status, highlight: bool) -> Vec<gemtext::Node> {
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!("")
};
let reblog_info = match post.reblog {
Some(status) => format!(", reblogging {}", status.account.display_name),
None => format!("")
};
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 heading_level = if highlight {
2
} else {
3
};
let detail_link = if highlight { post.url } else { None }.unwrap_or(format!("/status/{}", post.id));
let mut result = vec![
gemtext::Node::Heading { level: heading_level, body: post_info },
];
let post_actions = if highlight {
gemtext::Builder::new()
.link("?reply", Some("reply".to_string()))
.link("?boost", Some(format!("{}boost", undo_prefix(post.reblogged))))
.link("?fav", Some(format!("{}favorite", undo_prefix(post.favourited))))
.build()
} else {
vec![]
};
result.extend(strip_html(&post.content));
result.push(gemtext::Node::Link { to: detail_link, name: None });
result.extend(post_actions);
result.push(gemtext::Node::blank());
result
}
fn mastodon_data_path(req: &GeminiRequest) -> Option<PathBuf> {
let mut result = PathBuf::from("data");
result.push("users");
let hash = req.cert_hash()?;
result.push(hash);
Some(result.with_extension("toml"))
}
struct Handler;
impl Default for Handler {
fn default() -> Self {
Handler {}
}
}
impl Handler {
fn home(&self, req: GeminiRequest) -> 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"))
};
let home = mastodon.get_home_timeline().unwrap();
let before_timeline = gemtext::Builder::new()
.link("/post", Some("write a new post".to_string()))
.text("")
.heading(1, "your timeline")
.build();
let posts = home.items_iter()
.take(10)
.flat_map(|post| render_post(post, false));
let body = before_timeline.into_iter()
.chain(posts)
.collect::<Vec<_>>();
Ok(GeminiResponse::render(apply_global_template(body)))
}
fn post(&self, req: GeminiRequest) -> 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"))
};
match req.url.query() {
Some(post) => {
let post = percent_encoding::percent_decode_str(post).decode_utf8_lossy();
let new_status = StatusBuilder::new()
.status(post)
.build()
.unwrap();
let status = mastodon.new_status(new_status).unwrap();
Ok(temp_redirect(format!("/status/{}", status.id)))
}
None => {
Ok(GeminiResponse::input(format!("Post some text")))
}
}
}
fn auth_begin(&self, req: GeminiRequest) -> Result<GeminiResponse, Error> {
match req.url.query() {
Some(instance) => {
assert!(url::Host::parse(instance).is_ok(), "got a bad instance domain name");
let mut target = req.url.clone();
target.set_query(None);
target.path_segments_mut().unwrap().push(instance);
Ok(temp_redirect(target.to_string()))
}
None => {
Ok(GeminiResponse::input("Enter the domain of your instance (e.g. \"mastodon.social\")"))
}
}
}
fn auth_continue(&self, req: GeminiRequest, instance: &str) -> Result<GeminiResponse, Error> {
let app_install = get_app_install(instance);
let mut ready_for_code_url = req.url.clone();
ready_for_code_url.path_segments_mut().unwrap().push("code");
let response = gemtext::Builder::new()
.link(app_install.authorize_url().unwrap(), Some("Open this HTTP link to get an authorization code from your instance".to_string()))
.link(ready_for_code_url.into_string(), Some("Click this Gemini link to enter that authorization code here".to_string()))
.build();
return Ok(GeminiResponse::render(response));
}
fn auth_finish(&self, req: GeminiRequest, instance: &str) -> Result<GeminiResponse, Error> {
let app_install = get_app_install(instance);
let code = match req.url.query() {
Some(code) => code,
None => {
return Ok(GeminiResponse::input("Enter the authorization code you got"));
}
};
let mastodon = app_install.complete(code).unwrap();
// Save app data for using on the next run.
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"))
};
let status = mastodon.get_status(id);
let context = mastodon.get_context(id);
let (status, context) = match (status, context) {
(Ok(status), Ok(context)) => (status, context),
_ => return Ok(GeminiResponse::not_found()),
};
match req.url.query() {
Some("reply") => {
return Ok(temp_redirect(format!("/status/{}/reply", id)));
}
Some("boost") => {
let action = if status.reblogged.unwrap_or(false) {
mastodon.unreblog(id)
} else {
mastodon.reblog(id)
};
action.unwrap();
return Ok(temp_redirect(format!("/status/{}", id)));
}
Some("fav") => {
let action = if status.favourited.unwrap_or(false) {
mastodon.unfavourite(id)
} else {
mastodon.favourite(id)
};
action.unwrap();
return Ok(temp_redirect(format!("/status/{}", id)));
}
_ => {}
}
let posts = context.ancestors.into_iter()
.chain(iter::once(status))
.chain(context.descendants)
.flat_map(|post| {
let highlight = post.id == id;
render_post(post, highlight)
})
.collect();
Ok(GeminiResponse::render(apply_global_template(posts)))
}
fn reply(&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"))
};
match req.url.query() {
Some(post) => {
let post = percent_encoding::percent_decode_str(post).decode_utf8_lossy();
let new_status = StatusBuilder::new()
.status(post)
.in_reply_to(id)
.build()
.unwrap();
let status = mastodon.new_status(new_status).unwrap();
Ok(temp_redirect(format!("/status/{}", status.id)))
}
None => {
Ok(GeminiResponse::input(format!("Reply with some text")))
}
}
}
fn about(&self, _req: GeminiRequest) -> Result<GeminiResponse, Error> {
let response = gemtext::parse(concat!(r#"# About gemifedi v"#, env!("CARGO_PKG_VERSION"), r#"
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
=> / log in on this install of gemifedi
"#));
Ok(GeminiResponse::render(response))
}
fn logout(&self, req: GeminiRequest) -> 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!"))
};
if path.exists() {
fs::remove_file(path).unwrap();
}
Ok(GeminiResponse::gemini(b"Logged out successfully\n=> /about about gemifedi".to_vec()))
}
}
#[async_trait::async_trait]
impl GeminiHandler for Handler {
async fn handle(&self, req: GeminiRequest) -> Result<GeminiResponse, Error> {
let path = req.url.path().to_string();
route!(path, {
(/) => self.home(req);
(/"post") => self.post(req);
(/"auth") => self.auth_begin(req);
(/"auth"/[instance]) => self.auth_continue(req, instance);
(/"auth"/[instance]/"code") => self.auth_finish(req, instance);
(/"logout") => self.logout(req);
(/"status"/[id]) => self.status(req, id);
(/"status"/[id]/"reply") => self.reply(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(None, log::LevelFilter::Info)
.filter(Some("gemifedi"), 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(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(
rustls::internal::pemfile::certs(&mut cert_file).unwrap(),
key,
)?;
log::info!("about to start listening on gemini://{}{}/", options.domain, if options.port == 1965 { format!("") } else { format!(":{}", options.port) });
maj::server::serve(
Arc::new(Handler::default()),
tls_config,
options.domain,
options.port,
).await
}