Skip to content

Commit

Permalink
feat: initial release
Browse files Browse the repository at this point in the history
  • Loading branch information
Fuwn committed Jun 23, 2024
0 parents commit 35ddc90
Show file tree
Hide file tree
Showing 19 changed files with 1,273 additions and 0 deletions.
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: 💜 Test

on:
push:
branches:
- main
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: erlef/setup-beam@v1
with:
otp-version: "26.0.2"
gleam-version: "1.2.1"
rebar3-version: "3"
# elixir-version: "1.15.4"
- run: gleam deps download
- run: gleam test
- run: gleam format --check src test
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Gleam
*.beam
*.ez
/build
erl_crash.dump

# Visual Studio Code
.vscode
39 changes: 39 additions & 0 deletions Earthfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
VERSION 0.8

all:
BUILD +docker

docker:
ARG tag=latest

FROM ghcr.io/gleam-lang/gleam:v1.2.1-erlang-alpine

COPY +build/erlang-shipment/ /momoka/erlang-shipment/

WORKDIR /momoka/

ENTRYPOINT ["./erlang-shipment/entrypoint.sh"]

CMD ["run"]

SAVE IMAGE --push fuwn/momoka:${tag}

deps:
FROM ghcr.io/gleam-lang/gleam:v1.2.0-erlang-alpine

RUN apk add --no-cache build-base

build:
FROM +deps

WORKDIR /momoka/

COPY src/ /momoka/src/
COPY gleam.toml /momoka/
COPY manifest.toml /momoka/

RUN gleam build \
&& cd build/ \
&& gleam export erlang-shipment

SAVE ARTIFACT /momoka/build/erlang-shipment/
674 changes: 674 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 🏕️ Momoka

> A Gemini-to-Gopher Proxy
Momoka is a Gopher proxy that sits in between Gopher clients and Gemini servers.
It translates any Gemini requests containing Gemtext into Gopher-compatible
responses.

Momoka is written in under 300 (280) lines of code and written in the functional
[Gleam](https://gleam.run). It's designed to be small and simple.

## Usage

If you'd like to test out a production deployment of Momoka, you can visit
[`gopher://fuwn.me:70/1`](gopher://fuwn.me:70/1).

### Local

```bash
$ git clone [email protected]:Fuwn/momoka.git
$ cd momoka
$ gleam run
$ # or
$ nix run
```

### Docker

```shell
docker run -p '70:70' --rm fuwn/momoka:latest
```

### Proxy

By default, top-level requests, like `gopher://fuwn.me/1`, are proxied to
their mapped Gemini equivalents. Here are a few examples.

- [`gopher://fuwn.me`](gopher://fuwn.me) =>
[`gemini://fuwn.me`](gemini://fuwn.me)
- [`gopher://fuwn.me/1`](gopher://fuwn.me/1) =>
[`gemini://fuwn.me`](gemini://fuwn.me)
- [`gopher://fuwn.me/1/index2`](gopher://fuwn.me/1/index2) =>
[`gemini://fuwn.me/index2`](gemini://fuwn.me/index2)

Prepending `/proxy/` to the path will allow you to proxy any Gemini server.
Here are a few examples.

- [`gopher://fuwn.me/1/proxy/geminiprotocol.net`](gopher://fuwn.me/1/proxy/geminiprotocol.net)
=> [`gemini://geminiprotocol.net`](gemini://geminiprotocol.net)
- [`gopher://fuwn.me/1/proxy/fuwn.me/index2`](gopher://fuwn.me/1/proxy/fuwn.me/index2)
=> [`gemini://fuwn.me/index2`](`gemini://fuwn.me/index2`)

### Configuration

Momoka contains three environment variables that can be set to your liking.

- `ROOT` – The root Gemini capsule to proxy for top-level requests (defaults to
`fuwn.me`)
- `PORT` – The port to listen on for Gopher clients (defaults to `70`)
- `GEMINI_PROXY` – A raw-Gemtext producing Gemini-to-HTTP proxy. (defaults to
the [fuwn.me](https://fuwn.me)
[September](https://github.com/gemrest/september) instance)

## GemRest

I'm also the author of [GemRest](https://github.com/gemrest), the largest
organisation of Gemini-oriented software, tooling, and libraries. If you're
interested in Gemini, I'd recommend checking it out.

## Licence

This project is licensed with the [GNU General Public License v3.0](./LICENSE).
35 changes: 35 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
description = "Gemini-to-Gopher Proxy";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
nix-gleam.url = "github:arnarg/nix-gleam";
gitignore = {
url = "github:hercules-ci/gitignore.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, nix-gleam, gitignore, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [
nix-gleam.overlays.default
];
};
inherit (gitignore.lib) gitignoreSource;
in
{
packages.default = pkgs.buildGleamApplication {
src = gitignoreSource ./.;
rebar3Package = pkgs.rebar3WithPlugins {
plugins = with pkgs.beamPackages; [ pc ];
};
};
devShell = pkgs.mkShell {
buildInputs = [ pkgs.gleam pkgs.rebar3 ];
};
}
);
}
21 changes: 21 additions & 0 deletions gleam.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# For a full reference of all the available options, you can have a look at
# https://gleam.run/writing-gleam/gleam-toml/.

name = "momoka"
version = "1.0.0"
gleam = ">= 0.2.1"
description = "Gemini-to-Gopher Proxy"
licenses = ["GPL-3.0-only"]
repository = { type = "github", user = "Fuwn", repo = "momoka" }

[dependencies]
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
glisten = ">= 3.0.0 and < 4.0.0"
gleam_erlang = ">= 0.25.0 and < 1.0.0"
gleam_otp = ">= 0.10.0 and < 1.0.0"
gleam_httpc = ">= 2.2.0 and < 3.0.0"
gleam_http = ">= 3.6.0 and < 4.0.0"
envoy = ">= 1.0.1 and < 2.0.0"

[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
25 changes: 25 additions & 0 deletions manifest.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# This file was generated by Gleam
# You typically do not need to edit this file

packages = [
{ name = "envoy", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "CFAACCCFC47654F7E8B75E614746ED924C65BD08B1DE21101548AC314A8B6A41" },
{ name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" },
{ name = "gleam_http", version = "3.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "8C07DF9DF8CC7F054C650839A51C30A7D3C26482AC241C899C1CEA86B22DBE51" },
{ name = "gleam_httpc", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "CF76C71002DEECF6DC5D9CA83D962728FAE166B57926BE442D827004D3C7DF1B" },
{ name = "gleam_otp", version = "0.10.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "0B04FE915ACECE539B317F9652CAADBBC0F000184D586AAAF2D94C100945D72B" },
{ name = "gleam_stdlib", version = "0.38.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "663CF11861179AF415A625307447775C09404E752FF99A24E2057C835319F1BE" },
{ name = "gleeunit", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "72CDC3D3F719478F26C4E2C5FED3E657AC81EC14A47D2D2DEBB8693CA3220C3B" },
{ name = "glisten", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "AEB79A7244CB553A52974EDB87E652607E82749E4EC84B47B1F1BC926F1673C8" },
{ name = "logging", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "A996064F04EF6E67F0668FD0ACFB309830B05D0EE3A0C11BBBD2D4464334F792" },
{ name = "telemetry", version = "1.2.1", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "DAD9CE9D8EFFC621708F99EAC538EF1CBE05D6A874DD741DE2E689C47FEAFED5" },
]

[requirements]
envoy = { version = ">= 1.0.1 and < 2.0.0"}
gleam_erlang = { version = ">= 0.25.0 and < 1.0.0" }
gleam_http = { version = ">= 3.6.0 and < 4.0.0" }
gleam_httpc = { version = ">= 2.2.0 and < 3.0.0" }
gleam_otp = { version = ">= 0.10.0 and < 1.0.0" }
gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
glisten = { version = ">= 3.0.0 and < 4.0.0" }
49 changes: 49 additions & 0 deletions src/gemini.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import envoy
import gemtext/parse
import gleam/bit_array
import gleam/bytes_builder
import gleam/http/request
import gleam/httpc
import gleam/string
import gopher

pub fn get_gemtext_from_capsule(message) {
let root_capsule = case envoy.get("ROOT") {
Ok(capsule) -> capsule
_ -> "fuwn.me"
}
// Gleam isn't mature enough to even have an SSL/TLS libraries that I could
// find, and Erlang FFI is unclear, so Momoka uses the
// [September](https://github.com/gemrest/september) proxy deployed at
// <https://fuwn.me> to fetch the raw Gemini content.
//
// I'm sure as the language grows, this will be replaced with a more direct
// approach.
let gemini_proxy = case envoy.get("GEMINI_PROXY") {
Ok(proxy) -> proxy
_ -> "https://fuwn.me/raw/"
}
let assert Ok(request) = case bit_array.to_string(message) {
Ok(path) -> {
case path {
"/\r\n" | "\r\n" -> request.to(gemini_proxy <> root_capsule)
"/proxy/" <> route ->
request.to(gemini_proxy <> string.replace(route, "\r\n", ""))
"/" <> path ->
request.to(
gemini_proxy
<> root_capsule
<> "/"
<> string.replace(path, "\r\n", ""),
)
_ -> request.to(root_capsule <> string.replace(path, "\r\n", ""))
}
}
_ -> request.to(root_capsule)
}
let assert Ok(response) = httpc.send(request)

bytes_builder.from_string(
gopher.gemtext_to_gopher(parse.parse_gemtext_raw(response.body)) <> "\r\n",
)
}
20 changes: 20 additions & 0 deletions src/gemtext/blockquote.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import gemtext/gemtext.{type Gemtext}
import gleam/list
import gleam/string

pub fn combine_adjacent_blockquote_lines(lines: List(Gemtext)) -> List(Gemtext) {
case lines {
[gemtext.BlockquoteLine(a), gemtext.BlockquoteLine(b), ..rest] ->
combine_adjacent_blockquote_lines([
gemtext.Blockquote(string.join([a, b], "\n")),
..rest
])
[gemtext.Blockquote(a), gemtext.BlockquoteLine(b), ..rest] ->
combine_adjacent_blockquote_lines([
gemtext.Blockquote(string.join([a, b], "\n")),
..rest
])
[g, ..rest] -> list.append([g], combine_adjacent_blockquote_lines(rest))
[] -> []
}
}
14 changes: 14 additions & 0 deletions src/gemtext/gemtext.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import gleam/option.{type Option}

pub type Gemtext {
Text(String)
Link(to: String, Option(String))
Heading(String, depth: Int)
ListLine(String)
List(List(List(String)))
BlockquoteLine(String)
Blockquote(String)
Preformatted(description: Option(String), body: String)
PreformattedLine(String)
Whitespace
}
17 changes: 17 additions & 0 deletions src/gemtext/heading.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import gleam/string

pub fn count_leading_hashes(line: String) -> Int {
do_count_leading_hashes(string.to_graphemes(line), 0)
}

fn do_count_leading_hashes(characters: List(String), accumulator: Int) -> Int {
case characters {
[c, ..rest] -> {
case c {
"#" -> do_count_leading_hashes(rest, accumulator + 1)
_ -> accumulator
}
}
_ -> accumulator
}
}
16 changes: 16 additions & 0 deletions src/gemtext/list.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import gemtext/gemtext.{type Gemtext}
import gleam/list

pub fn group_adjacent_list_items(lines: List(Gemtext)) -> List(Gemtext) {
case lines {
[gemtext.ListLine(a), gemtext.ListLine(b), ..rest] ->
group_adjacent_list_items([gemtext.List([[a], [b]]), ..rest])
[gemtext.List(lists), gemtext.ListLine(item), ..rest] ->
group_adjacent_list_items([
gemtext.List(list.append(lists, [[item]])),
..rest
])
[g, ..rest] -> list.append([g], group_adjacent_list_items(rest))
[] -> []
}
}
49 changes: 49 additions & 0 deletions src/gemtext/parse.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import gemtext/blockquote
import gemtext/gemtext.{type Gemtext}
import gemtext/heading
import gemtext/list as gemtext_list
import gemtext/preformatted_block
import gleam/list
import gleam/option.{None, Some}
import gleam/string

pub fn parse_gemtext_line(line) -> Gemtext {
let trimmed_line = string.trim(line)

case string.split(trimmed_line, " ") {
["=>", to] -> gemtext.Link(to, None)
["=>", to, ..title] -> gemtext.Link(to, Some(string.join(title, " ")))
[">", ..rest] ->
gemtext.BlockquoteLine(string.trim_left(string.join(rest, " ")))
["*", ..rest] -> gemtext.ListLine(string.trim_left(string.join(rest, " ")))
[""] -> gemtext.Whitespace
_ -> {
case string.to_graphemes(trimmed_line) {
["#", ..rest] ->
gemtext.Heading(
string.trim_left(string.replace(string.join(rest, ""), "#", "")),
heading.count_leading_hashes(line),
)
["`", "`", "`", ..rest] ->
gemtext.PreformattedLine(string.trim_left(
"```\n" <> string.join(rest, ""),
))
_ -> gemtext.Text(line)
}
}
}
}

pub fn parse_gemtext(text: String) -> List(Gemtext) {
string.split(text, "\n")
|> preformatted_block.group_adjacent_preformatted_block_lines
|> list.map(parse_gemtext_line)
|> gemtext_list.group_adjacent_list_items
|> blockquote.combine_adjacent_blockquote_lines
}

pub fn parse_gemtext_raw(text: String) -> List(Gemtext) {
string.split(text, "\n")
|> preformatted_block.group_adjacent_preformatted_block_lines
|> list.map(parse_gemtext_line)
}
Loading

0 comments on commit 35ddc90

Please sign in to comment.