diff options
| author | Fuwn <[email protected]> | 2022-04-01 01:59:13 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2022-04-01 01:59:13 +0000 |
| commit | a36a9c15900be9f2aeb15249595cd81d5c35f9e4 (patch) | |
| tree | 1df8260acc1feb9cbcbe9628873f45e7aa8c5ac7 | |
| parent | docs(license): add license (diff) | |
| download | september-a36a9c15900be9f2aeb15249595cd81d5c35f9e4.tar.xz september-a36a9c15900be9f2aeb15249595cd81d5c35f9e4.zip | |
feat: 0.0.0 :star:
| -rw-r--r-- | .dockerignore | 10 | ||||
| -rw-r--r-- | .env.example | 6 | ||||
| -rw-r--r-- | .github/workflows/check.yaml | 35 | ||||
| -rw-r--r-- | .gitignore | 10 | ||||
| -rw-r--r-- | .license_template | 17 | ||||
| -rw-r--r-- | Cargo.toml | 42 | ||||
| -rw-r--r-- | Dockerfile | 24 | ||||
| -rw-r--r-- | Makefile.toml | 36 | ||||
| -rw-r--r-- | README.md | 130 | ||||
| -rw-r--r-- | build.rs | 19 | ||||
| -rw-r--r-- | docker-compose.yaml | 11 | ||||
| -rw-r--r-- | rust-toolchain.toml | 2 | ||||
| -rw-r--r-- | rustfmt.toml | 30 | ||||
| -rw-r--r-- | src/main.rs | 325 |
14 files changed, 697 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ed926f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# Rust +target/ +**/*.rs.bk +Cargo.lock + +# IDE +.idea/ + +# Development +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..75bcb55 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +PORT=8080 +ROOT=gemini://fuwn.me +CSS_EXTERNAL=https://example.com/style.css +KEEP_GEMINI_EXACT=gemini://fuwn.me/skills +# KEEP_GEMINI_DOMAIN=fuwn.me +# PROXY_BY_DEFAULT=true diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 0000000..00d8c34 --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,35 @@ +name: Check ✅ + +on: + workflow_dispatch: + push: + paths: + - "*" + pull_request: + paths: + - "*" + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout 🛒 + uses: actions/checkout@v3 + + - name: Toolchain 🧰 + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: nightly-2022-03-08 + components: rustfmt, clippy + override: true + + - name: Check ✅ + uses: actions-rs/cargo@v1 + continue-on-error: false + with: + command: check + args: --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed926f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Rust +target/ +**/*.rs.bk +Cargo.lock + +# IDE +.idea/ + +# Development +.env diff --git a/.license_template b/.license_template new file mode 100644 index 0000000..c3c7133 --- /dev/null +++ b/.license_template @@ -0,0 +1,17 @@ +// This file is part of September <https://github.com/gemrest/september>. +// Copyright (C) {20\d{2}(-20\d{2})?} Fuwn <[email protected]> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// Copyright (C) {20\d{2}(-20\d{2})?} Fuwn <[email protected]> +// SPDX-License-Identifier: GPL-3.0-only diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3e29e5f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[package] +name = "september" +version = "0.0.0" +authors = ["Fuwn <[email protected]>"] +edition = "2021" +description = "A simple and efficient Gemini-to-HTTP proxy." +readme = "README.rst" +homepage = "https://github.com/gemrest/september" +repository = "https://github.com/gemrest/september" +license = "GPL-3.0-only" +keywords = ["rust", "gemini", "proxy"] +categories = ["web-programming", "web-programming::http-server"] +publish = false + +# Slower builds, faster executables +[profile.release] +lto = "fat" +codegen-units = 1 +opt-level = 3 + +[dependencies] +# Gemini +gmi = "0.2.1" + +# HTTP +actix-web = "4.0.1" + +# Logging +pretty_env_logger = "0.4.0" +log = "0.4.16" + +# Environment Variables +dotenv = "0.15.0" + +[build-dependencies] +# Environment Variables +vergen = "7.0.0" + +# `Result` +anyhow = "1.0.56" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f6cb13 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM rust:latest as builder + +RUN update-ca-certificates + +WORKDIR /september + +COPY ./ ./ + +RUN cargo build --release + +RUN strip -s /september/target/release/september + +FROM debian:buster-slim + +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group + +WORKDIR /september + +COPY --from=builder /september/target/release/september ./ + +EXPOSE 80 + +CMD ["./september"] diff --git a/Makefile.toml b/Makefile.toml new file mode 100644 index 0000000..60b05a7 --- /dev/null +++ b/Makefile.toml @@ -0,0 +1,36 @@ +# ------------ +# | Wrappers | +# ------------ +[tasks.fmt] +command = "cargo" +args = ["fmt"] +private = true + +[tasks.check] +command = "cargo" +args = ["check"] +private = true + +[tasks.clippy] +command = "cargo" +args = ["clippy"] +private = true + +# ------------- +# | Executors | +# ------------- +[tasks.checkf] +dependencies = ["fmt", "check"] + +[tasks.checkfc] +dependencies = ["fmt", "check", "clippy"] + +[tasks.run] +dependencies = ["checkfc"] +command = "cargo" +args = ["run"] + +[tasks.docker-build] +dependencies = ["checkfc"] +command = "docker" +args = ["build", "-t", "fuwn/september", "."] diff --git a/README.md b/README.md new file mode 100644 index 0000000..91ea929 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# September + +[](https://github.com/gemrest/september/actions/workflows/check.yaml) + +A simple and efficient Gemini-to-HTTP proxy written in Rust. + +## Usage + +### Docker + +```shell +$ docker run -d [ -e ROOT="gemini://fuwn.me" ] [ -e PORT="8080"] [ -e CSS_EXTERNAL="https://example.com/style.css"] fuwn/september:latest +``` + +### Docker Compose + +Edit the `docker-compose.yaml` file to your liking, and then + +```shell +$ docker-compose up -d +``` + +### Executable + +```shell +$ [ ROOT="gemini://fuwn.me" ] [ PORT="8080"] [ CSS_EXTERNAL="https://example.com/style.css"] ./september +``` + +or use a `.env` file + +```dotenv +# .env + +ROOT=gemini://fuwn.me +PORT=8080 +CSS_EXTERNAL=https://example.com/style.css +``` + +and then + +```shell +$ ./september +``` + +## Configuration + +Configuration for September is done solely via environment variables, for +simplicity, and Docker support. + +### `PORT` + +Bind September to a custom port. + +Generally, you shouldn't touch this if you are deploying using Docker. + +If no `PORT` is provided or the `PORT` could not be properly parsed as a `u16`; +port `80` will be assumed. + +```dotenv +PORT=8080 +``` + +### `ROOT` + +The root Gemini capsule to proxy when not visiting a "/proxy" route. + +If no `ROOT` is provided, `"gemini://fuwn.me"` will be assumed. + +```dotenv +ROOT=gemini://fuwn.me +``` + +### `CSS_EXTERNAL` + +An external CSS file to apply to the HTML response. + +If no `CSS_EXTERNAL` is provided, there will be no styling done to the HTML +response. + +```dotenv +CSS_EXTERNAL=https://cdnjs.cloudflare.com/ajax/libs/mini.css/3.0.1/mini-default.min.css +``` + +### `KEEP_GEMINI_EXACT` + +Keeps exactly matching URLs as a Gemini URL. + +#### Examples + +If `KEEP_GEMINI_EXACT` is equal to `KEEP_GEMINI_EXACT=gemini://fuwn.me/gemini`, +all routes will be proxied their "/proxy" equivalent (e.g., +"https://fuwn.me/proxy/fuwn.me/gopher"), except occurrences of +"gemini://fuwn.me/skills" will be kept as is. + +```dotenv +KEEP_GEMINI_EXACT=gemini://fuwn.me/skills +``` + +### `KEEP_GEMINI_DOMAIN` + +Similar to `KEEP_GEMINI_EXACT`, except proxies based on entire domains instead +of exact matches. + +```dotenv +KEEP_GEMINI_DOMAIN=fuwn.me +``` + +### `PROXY_BY_DEFAULT` + +Control weather or not all Gemini URLs will be proxied. + +Similar to `KEEP_GEMINI_EXACT` and `KEEP_GEMINI_DOMAIN` but global. + +Defaults to `true`. + +```dotenv +PROXY_BY_DEFAULT=false +``` + +## Capsules using September + +[Add yours!](https://github.com/gemrest/september/edit/main/README.md) + +- <https://fuwn.me/> +- <https://gem.rest/> + +## License + +This project is licensed with the +[GNU General Public License v3.0](https://github.com/gemrest/september/blob/main/LICENSE). diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..3a31cf4 --- /dev/null +++ b/build.rs @@ -0,0 +1,19 @@ +// This file is part of September <https://github.com/gemrest/september>. +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// SPDX-License-Identifier: GPL-3.0-only + +fn main() -> anyhow::Result<()> { vergen::vergen(vergen::Config::default()) } diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..896f490 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,11 @@ +services: + september: + ports: + - "8080:80" + environment: + - "ROOT=gemini://fuwn.me" + - "CSS_EXTERNAL=https://example.com/style.css" + - "KEEP_GEMINI_EXACT=gemini://fuwn.me/skills" + # - "KEEP_GEMINI_DOMAIN=fuwn.me" + - "PROXY_BY_DEFAULT=true" + image: "fuwn/september:latest" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..099ab4b --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly-2022-03-08" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..1dd4c90 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,30 @@ +condense_wildcard_suffixes = true +edition = "2021" +enum_discrim_align_threshold = 20 +# error_on_line_overflow = true +# error_on_unformatted = true +fn_single_line = true +force_multiline_blocks = true +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +imports_layout = "HorizontalVertical" +license_template_path = ".license_template" +max_width = 80 +match_arm_blocks = false +imports_granularity = "Crate" +newline_style = "Unix" +normalize_comments = true +normalize_doc_attributes = true +reorder_impl_items = true +group_imports = "StdExternalCrate" +reorder_modules = true +report_fixme = "Always" +# report_todo = "Always" +struct_field_align_threshold = 20 +struct_lit_single_line = false +tab_spaces = 2 +use_field_init_shorthand = true +use_try_shorthand = true +where_single_line = true +wrap_comments = true diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4f71529 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,325 @@ +// This file is part of September <https://github.com/gemrest/september>. +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// SPDX-License-Identifier: GPL-3.0-only + +#![deny( + warnings, + nonstandard_style, + unused, + future_incompatible, + rust_2018_idioms, + unsafe_code +)] +#![deny(clippy::all, clippy::nursery, clippy::pedantic)] +#![recursion_limit = "128"] +#![allow(clippy::cast_precision_loss)] + +#[macro_use] +extern crate log; + +use std::{env::var, time::Instant}; + +use actix_web::{web, Error, HttpResponse}; +use gmi::{protocol::Response, url::Url}; + +#[allow(clippy::too_many_lines)] +fn gemini_to_html(response: &Response, url: &Url) -> (String, String) { + let mut response_string = String::new(); + let mut in_block = false; + let mut in_list = false; + let mut title = String::new(); + + for line in String::from_utf8_lossy(&response.data).to_string().lines() { + match line.get(0..1).unwrap_or("") { + // Convert links + "=" => { + let line = line.replace("=>", "").trim_start().to_owned(); + let mut split = line.split(' ').collect::<Vec<_>>(); + let mut href = split.remove(0).to_string(); + let text = split.join(" "); + + if var("PROXY_BY_DEFAULT").unwrap_or_else(|_| "true".to_string()) + == "true" + && href.contains("gemini://") + { + href = format!("/proxy/{}", href.trim_start_matches("gemini://")); + } + + if let Ok(keeps) = var("KEEP_GEMINI_EXACT") { + let mut keeps = keeps.split(','); + + if href.starts_with('/') { + let temporary_href = + format!("gemini://{}{}", url.authority.host, href); + + if keeps.any(|k| k == &*temporary_href) { + href = temporary_href; + } + } + } + + if let Ok(keeps) = var("KEEP_GEMINI_DOMAIN") { + if href.starts_with('/') + && keeps.split(',').any(|k| k == &*url.authority.host) + { + href = format!("gemini://{}{}", url.authority.host, href); + } + } + + response_string + .push_str(&format!("<p><a href=\"{}\">{}</a></p>\n", href, text)); + } + // Add whitespace + "" => { + if in_list { + in_list = false; + response_string.push_str("</ul>\n"); + } + + response_string.push('\n'); + } + // Convert lists + "*" => + if in_list { + response_string.push_str(&format!( + "<li>{}</li>\n", + line.replace('*', "").trim_start() + )); + } else { + in_list = true; + response_string.push_str("<ul>\n"); + }, + // Convert headings + "#" => { + if in_list { + in_list = false; + response_string.push_str("</ul>\n"); + } + + match line.get(0..2) { + Some(heading) => + match heading { + "###" => { + response_string.push_str(&format!( + "<h3>{}</h3>", + line.replace("###", "").trim_start() + )); + } + _ => + if heading.starts_with("##") { + response_string.push_str(&format!( + "<h2>{}</h2>", + line.replace("##", "").trim_start() + )); + } else { + let fixed_line = + line.replace('#', "").trim_start().to_owned(); + + response_string.push_str(&format!("<h1>{}</h1>", fixed_line)); + + if title.is_empty() { + title = fixed_line; + } + }, + }, + None => {} + } + } + // Convert blockquotes + ">" => { + if in_list { + in_list = false; + response_string.push_str("</ul>\n"); + } + + response_string.push_str(&format!( + "<blockquote>{}</blockquote>\n", + line.replace('>', "").trim_start() + )); + } + // Convert preformatted blocks + "`" => { + if in_list { + in_list = false; + response_string.push_str("</ul>\n"); + } + + in_block = !in_block; + if in_block { + response_string.push_str("<pre>\n"); + } else { + response_string.push_str("</pre>\n"); + } + } + // Add text lines + _ => { + if in_list { + in_list = false; + response_string.push_str("</ul>\n"); + } + + if in_block { + response_string.push_str(&format!("{}\n", line)); + } else { + response_string.push_str(&format!("<p>{}</p>", line)); + } + } + } + } + + (title, response_string) +} + +#[allow(clippy::unused_async, clippy::future_not_send)] +async fn default(req: actix_web::HttpRequest) -> Result<HttpResponse, Error> { + // Try to construct a Gemini URL + let url = match Url::try_from(&*if req.path().starts_with("/proxy") { + format!("gemini://{}", req.path().replace("/proxy/", "")) + } else if req.path().starts_with("/x") { + format!("gemini://{}", req.path().replace("/x/", "")) + } else { + // Try to set `ROOT` as `ROOT` environment variable, or use + // `"gemini://fuwn.me"` as default. + format!( + "{}{}", + { + if let Ok(root) = var("ROOT") { + root + } else { + warn!( + "could not use ROOT from environment variables, proceeding with \ + default root: gemini://fuwn.me" + ); + + "gemini://fuwn.me".to_string() + } + }, + req.path() + ) + }) { + Ok(url) => url, + Err(e) => { + return Ok(HttpResponse::Ok().body(e.to_string())); + } + }; + // Make a request to get Gemini content and time it. + let mut timer = Instant::now(); + let response = match gmi::request::make_request(&url) { + Ok(response) => response, + Err(e) => { + return Ok(HttpResponse::Ok().body(e.to_string())); + } + }; + let response_time_taken = timer.elapsed(); + + // Reset timer for below + timer = Instant::now(); + + // Convert Gemini Response to HTML and time it. + let mut html_context = gemini_to_html(&response, &url); + let convert_time_taken = timer.elapsed(); + + // Try to add an external stylesheet from the `CSS_EXTERNAL` environment + // variable. + if let Ok(css) = var("CSS_EXTERNAL") { + html_context.1.push_str(&format!( + "<link rel=\"stylesheet\" type=\"text/css\" href=\"{}\">", + css + )); + } + + // Add a title to HTML response + html_context + .1 + .push_str(&format!("<title>{}</title>", html_context.0)); + + // Add proxy information to footer of HTML response + html_context.1.push_str(&format!( + "<details>\n<summary>Proxy information</summary> +<dl> +<dt>Original URL</dt> +<dd><a href=\"{}\">{0}</a></dd> +<dt>Status code</dt> +<dd>{:?}</dd> +<dt>Meta</dt> +<dd>{}</dd> +<dt>Capsule response time</dt> +<dd>{} milliseconds</dd> +<dt>Gemini-to-HTML time</dt> +<dd>{} milliseconds</dd> +</dl> +<p>This content has been proxied by \ +<a href=\"https://github.com/gemrest/september{}\">September ({})</a>.</p> +</details>", + url, + response.status, + response.meta, + response_time_taken.as_nanos() as f64 / 1_000_000.0, + convert_time_taken.as_nanos() as f64 / 1_000_000.0, + format_args!("/tree/{}", env!("VERGEN_GIT_SHA")), + env!("VERGEN_GIT_SHA").get(0..5).unwrap_or("UNKNOWN"), + )); + + // Return HTML response + Ok( + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html_context.1), + ) +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + // Set the `RUST_LOG` environment variable so Actix can provide logs. + // + // This can be overridden using the `RUST_LOG` environment variable in + // configuration. + std::env::set_var("RUST_LOG", "actix_web=info"); + + // Initialise `dotenv` so we can access `.env` files. + dotenv::dotenv().ok(); + // Initialise logger so we can see logs + pretty_env_logger::init(); + + // Setup Actix web-server + actix_web::HttpServer::new(move || { + actix_web::App::new() + .default_service(web::get().to(default)) + .wrap(actix_web::middleware::Logger::default()) + }) + .bind(( + // Bind Actix web-server to localhost + "0.0.0.0", + // If the `PORT` environment variable is present, try to use it, otherwise; + // use port `80`. + if let Ok(port) = var("PORT") { + match port.parse::<_>() { + Ok(port) => port, + Err(e) => { + warn!("could not use PORT from environment variables: {}", e); + warn!("proceeding with default port: 80"); + + 80 + } + } + } else { + 80 + }, + ))? + .run() + .await +} |