diff --git a/.dockerignore b/.dockerignore
index dbc39fc..b848dce 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,6 @@
-# Ignore everything
**
-
-!maple
+!maple/
+!maple/**
+!tests/
+!tests/*.py
!build.ninja
-
diff --git a/.envrc b/.envrc
index 7e4312b..3550a30 100644
--- a/.envrc
+++ b/.envrc
@@ -1,9 +1 @@
-#!/usr/bin/env bash
-
-if type -P lorri &>/dev/null; then
- eval "$(lorri direnv)"
-else
- echo 'while direnv evaluated .envrc, could not find the command "lorri" [https://github.com/nix-community/lorri]'
-
- use flake
-fi
+use flake
diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml
new file mode 100644
index 0000000..c682996
--- /dev/null
+++ b/.github/workflows/check.yaml
@@ -0,0 +1,21 @@
+name: Check
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Test
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v6
+ - name: Install Build Dependencies
+ run: sudo apt-get update && sudo apt-get install -y clang ninja-build pkg-config libssl-dev
+ - name: Build and Test
+ run: ninja check
+ - name: Build and Test the Container Image
+ run: docker build --tag maple:test .
diff --git a/.gitignore b/.gitignore
index c3f4a1c..db0fd53 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,14 +5,19 @@
.idea
.vscode
-# Tup
-.tup
+# macOS
+.DS_Store
# Build Artifacts
build
# Ninja
.ninja_log
+.ninja_deps
# Nix
+.direnv/
result
+
+# Python Tests
+__pycache__/
diff --git a/Dockerfile b/Dockerfile
index cce24e0..6709053 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,32 +1,27 @@
-FROM alpine:latest as environment
+FROM alpine:3.23 AS builder
-RUN apk update \
- && apk upgrade \
- && apk add --no-cache libstdc++
+RUN apk add --no-cache clang g++ ninja pkgconf openssl-dev openssl python3
-FROM environment as build_environment
+WORKDIR /source
-RUN apk add --no-cache \
- clang \
- ninja \
- alpine-sdk \
- openssl-dev \
- linux-headers
+COPY build.ninja ./
+COPY maple ./maple
+COPY tests ./tests
-FROM build_environment as builder
+RUN ninja check
-WORKDIR /maple
-
-COPY ./ ./
+FROM alpine:3.23
-RUN ninja
-
-FROM environment
+RUN apk add --no-cache libstdc++ libssl3 libcrypto3 \
+ && addgroup -S -g 1000 maple \
+ && adduser -S -u 1000 -G maple maple
WORKDIR /maple
-COPY --from=builder /maple/build/maple ./
+COPY --from=builder /source/build/maple /usr/local/bin/maple
+
+USER maple
EXPOSE 1965
-ENTRYPOINT ["./maple"]
+ENTRYPOINT ["maple"]
diff --git a/README.md b/README.md
index 95f0536..1073e28 100644
--- a/README.md
+++ b/README.md
@@ -1,108 +1,82 @@
# Maple
-A very simple static Gemini server, now with Titan support!
+A small static Gemini server with optional Titan uploads.
-### Lines-of-code
+## Build and Run
-This codebase is now 351 lines of lines-of-code!
+Maple requires a C++20 Clang compiler, Ninja, pkg-config, and OpenSSL development
+libraries. Python 3 and the OpenSSL command-line tool are needed for tests.
-The statement "... written within a single file and liberally
-spanning 159 lines-of-code." has been removed.
+For a new capsule:
-If you would still like the minimal 159-LOC experience, checkout
-[49ce0f8](https://github.com/gemrest/maple/tree/49ce0f83b8abd1af4760e56c1673c6997ef8a2c4)!
-(Docker tag `0.1.4`) Do note, that version of this project is outdated and only
-has Gemini support.
-
-## Usage
-
-### Docker Compose
-
-A Docker Compose file is already setup [here](./docker-compose.yaml). The only
-field which should be modified is the `ports`, if need be.
-
-```shell
-docker-compose up -d
+```sh
+ninja
+ninja check
+bin/genkey
+printf '# Welcome\n' > .maple/gmi/index.gmi
+build/maple
```
-### Docker
-
-```shell
-docker run -p 1965:1965 -v ./your_capsules_directory:/maple/.maple fuwn/maple:latest
-```
-
-### Executable
-
-1. Build: `tup` (requires [Tup](https://gittup.org/tup/index.html))
-2. Run: `build/maple`, or
- `TITAN=1 TITAN_TOKEN=secret TITAN_MAX_SIZE=2048 build/maple`
-
-### Certificates
-
-Before any possible method of usage; you must generate or use a preexisting
-server certificate.
-
-If you do not have an existing certificate, generate one using the provided
-certificate generations script: [`bin/genkey`](bin/genkey) (requires
-[OpenSSL](https://www.openssl.org/)).
+Maple listens on TCP port 1965 by default. Set `MAPLE_PORT` to an unsigned decimal
+integer from 1 to 65535 to use another port, for example `MAPLE_PORT=1966 build/maple`.
-The server certificate must be placed in a directory -- directly adjacent to the
-Docker Compose file or executable -- named [`.maple`](.maple). The public and
-private keys must be named `public.pem` and `private.pem`, respectively.
+With Nix:
-### Gemini Content
-
-Gemini content must be placed in a directory -- directly adjacent to the
-Docker Compose file or executable -- named [`.maple/gmi`](.maple/gmi) and
-ending with the file extension `.gmi`.
-
-### Environment Variables
-
-Maple can be configured with three optional environment variables:
-
-#### `TITAN`
-
-Enable Titan support.
-
-Takes a value of either `true` or `1`, case-insensitive.
-
-Defaults to off.
+```sh
+nix develop
+nix flake check
+nix build
+```
-#### `TITAN_TOKEN`
+## Content and Certificates
-Set a token to restrict Titan access.
+All paths are relative to the server's **working directory**, not its executable:
-Takes a string value, case-sensitive.
+```text
+.maple/
+ public.pem
+ private.pem
+ gmi/
+ index.gmi
+```
-Defaults to no token, **anyone can upload!**
+`bin/genkey` creates a self-signed certificate and refuses to overwrite existing
+keys. You can instead provide your own certificate and matching private key.
+Keep the private key readable only by the account running Maple.
-#### `TITAN_MAX_SIZE`
+Maple serves regular `.gmi` files; directory requests use `index.gmi`. Changes
+become visible without a restart. URL path components are percent-decoded once;
+queries do not affect file selection. Symlinks, encoded separators, NUL bytes,
+and `.` or `..` path components are rejected. Content directories must already
+exist before uploading into them.
-The maximum size in bytes which a Titan upload is permitted to perform.
+## Titan
-Takes an integer.
+| Variable | Default | Meaning |
+| --- | --- | --- |
+| `TITAN` | Disabled | `1` or `true`, case-insensitive, enables uploads. |
+| `TITAN_TOKEN` | Empty | Required upload token. An empty token permits unauthenticated uploads. |
+| `TITAN_MAX_SIZE` | `1024` | Maximum upload bytes, written as an unsigned decimal integer. |
-Default to `1024`.
+Uploads require `mime=text/gemini`, a valid `size`, and a `.gmi` destination.
+Completed uploads replace files atomically; incomplete uploads preserve them.
+New files are private to the server account.
-### Hacking
+Maple serves one connection at a time. The TLS handshake and request header share
+a five-second absolute deadline. Uploads and responses have a 15-second
+inactivity timeout.
-If you decide to tamper with Maple, you should give your commits a test. As
-there isn't a test suite yet, feel free to use any of these examples:
+## Docker
-```bash
-# Using OpenSSL directly to debug different requests:
-$ openssl s_client -ign_eof -quiet -connect localhost:1965 <<< \
-> "gemini://localhost:1965"
+Prepare `.maple` as above, then run:
-# Using Bollux to debug proper requests... or any client!
-$ bollux localhost
+```sh
+MAPLE_UID=$(id -u) MAPLE_GID=$(id -g) docker compose up --build -d
```
-## Capsules using Maple
-
-[Add yours!](https://github.com/gemrest/maple/edit/main/README.md)
-
-- ()
+The user and group overrides let the container read your private key and write
+uploads without widening file permissions. Compose passes through the three
+Titan variables; uploads remain disabled by default.
## License
diff --git a/Tupfile b/Tupfile
deleted file mode 100644
index 26a606d..0000000
--- a/Tupfile
+++ /dev/null
@@ -1,22 +0,0 @@
-NAME = maple
-
-# Input & Output Directories
-SOURCE_DIRECTORY = $(NAME)
-INCLUDE_DIRECTORY = $(NAME)
-BUILD_DIRECTORY = build
-
-# Compiler Configuration
-CXX = clang++
-CXX_EXTENSION = cc
-CXX_FLAGS = -std=c++23 -I $(INCLUDE_DIRECTORY) -Weverything -Wno-padded -Wno-c++98-compat -MMD -Wno-c++98-compat-pedantic
-LD_FLAGS = -lssl -lcrypto
-
-# Clang-Tidy Configuration
-CLANG_TIDY_CHECKS = '-*,bugprone-*,clang-analyzer-*,concurrency-*,cppcoreguildelines-*,llvm-*,misc-*,modernize-*,performance-*,portability-*,readability-*,-readability-function-cognitive-complexity,-bugprone-easily-swappable-parameters,-concurrency-mt-unsafe'
-CLANG_TIDY_FLAGS = -checks=$(CLANG_TIDY_CHECKS) -warnings-as-errors=* -quiet
-
-# : foreach $(SOURCE_DIRECTORY)/*.$(CXX_EXTENSION) $(INCLUDE_DIRECTORY)/*.hh |> clang-format -i -style=LLVM %f |>
-: foreach $(SOURCE_DIRECTORY)/*.$(CXX_EXTENSION) |> clang-tidy $(CLANG_TIDY_FLAGS) %f -- $(CXX_FLAGS) |>
-: foreach $(SOURCE_DIRECTORY)/*.$(CXX_EXTENSION) |> ^j^ $(CXX) $(CXX_FLAGS) -MF $(BUILD_DIRECTORY)/%B.d -c %f -o %o |> $(BUILD_DIRECTORY)/%B.o | $(BUILD_DIRECTORY)/%B.d
-: $(BUILD_DIRECTORY)/*.o |> $(CXX) $(LD_FLAGS) %f -o %o |> $(BUILD_DIRECTORY)/$(NAME)
-
diff --git a/bin/genkey b/bin/genkey
index e081711..0ebb245 100755
--- a/bin/genkey
+++ b/bin/genkey
@@ -1,18 +1,18 @@
#!/usr/bin/env bash
-read \
- -rp \
- "Which common name (hostname) would you like to use for the generated key? " \
- common_name
+set -euo pipefail
+umask 077
-openssl req \
- -new \
- -subj "/CN=$common_name" \
- -x509 \
- -newkey ec \
+if [[ -e .maple/private.pem || -L .maple/private.pem ||
+ -e .maple/public.pem || -L .maple/public.pem ]]; then
+ echo 'A certificate or key already exists in .maple; refusing to overwrite it.' >&2
+
+ exit 1
+fi
+
+mkdir -p .maple/gmi
+read -r -p 'Hostname for the Certificate: ' common_name
+openssl req -new -x509 -newkey ec \
-pkeyopt ec_paramgen_curve:prime256v1 \
- -days 365 \
- -nodes \
- -out .maple/public.pem \
- -keyout .maple/private.pem \
- -inform pem
+ -subj "/CN=$common_name" -days 365 -nodes \
+ -out .maple/public.pem -keyout .maple/private.pem
diff --git a/build.ninja b/build.ninja
index 861ce91..bc58ae2 100644
--- a/build.ninja
+++ b/build.ninja
@@ -1,20 +1,38 @@
-cc = clang++
-cxxflags = -std=c++20 -Weverything -Wno-padded -Wno-c++98-compat -MMD -Wno-c++98-compat-pedantic
-ldflags = -lssl -lcrypto
-outdir = build
-name = maple
-srcdir = $name
+compiler = clang++
+compiler_flags = -std=c++20 -O2 -Weverything -Wno-padded -Wno-c++98-compat -Wno-c++98-compat-pedantic
+build_directory = build
+
+platform_warning_flags = $$(if test -f /etc/alpine-release; then $
+ printf '%s ' '-Wno-disabled-macro-expansion'; $
+ elif test "$$(uname -s)" = Darwin; then $
+ case "$$($compiler --version)" in $
+ ("Apple clang"*) $
+ case "$$($compiler $compiler_flags -print-effective-triple)" in $
+ ("$$(uname -m)"-apple-darwin*|"$$(uname -m)"-apple-macos*) $
+ printf '%s ' '-Wno-poison-system-directories';; $
+ esac;; $
+ esac; $
+ fi)
rule compile
- command = $cc $cxxflags -c $in -o $out
+ command = $compiler $compiler_flags $platform_warning_flags $$(pkg-config --cflags openssl | sed -E 's/(^| )-I/\1-isystem /g') -MMD -MF $out.d -c $in -o $out
+ depfile = $out.d
+ deps = gcc
rule link
- command = $cc $in -o $out $ldflags
+ command = $compiler $in -o $out $$(pkg-config --libs openssl)
-build $outdir/$name.o: compile $srcdir/$name.cc
-build $outdir/gemini.o: compile $srcdir/gemini.cc
-build $outdir/titan.o: compile $srcdir/titan.cc
+rule test
+ command = python3 -m unittest discover -s tests -v
+ pool = console
-build $outdir/$name: link $outdir/$name.o $outdir/gemini.o $outdir/titan.o
+build $build_directory/maple.o: compile maple/maple.cc
+build $build_directory/gemini.o: compile maple/gemini.cc
+build $build_directory/titan.o: compile maple/titan.cc
+build $build_directory/content.o: compile maple/content.cc
+build $build_directory/connection.o: compile maple/connection.cc
+build $build_directory/uri.o: compile maple/uri.cc
+build $build_directory/maple: link $build_directory/maple.o $build_directory/gemini.o $build_directory/titan.o $build_directory/content.o $build_directory/connection.o $build_directory/uri.o
+build check: test $build_directory/maple
-default $outdir/$name
+default $build_directory/maple
diff --git a/docker-compose.yaml b/docker-compose.yaml
index bee1edf..cc7d7a6 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -1,7 +1,13 @@
services:
maple:
+ build: .
+ image: maple:local
+ user: "${MAPLE_UID:-1000}:${MAPLE_GID:-1000}"
ports:
- "1965:1965"
volumes:
- "./.maple:/maple/.maple"
- image: "fuwn/maple:latest"
+ environment:
+ TITAN: "${TITAN:-0}"
+ TITAN_TOKEN: "${TITAN_TOKEN:-}"
+ TITAN_MAX_SIZE: "${TITAN_MAX_SIZE:-1024}"
diff --git a/flake.lock b/flake.lock
index 55d28d1..61eef25 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,77 +1,41 @@
{
"nodes": {
- "flake-compat": {
- "flake": false,
- "locked": {
- "lastModified": 1696426674,
- "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
- "owner": "edolstra",
- "repo": "flake-compat",
- "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
- "type": "github"
- },
- "original": {
- "owner": "edolstra",
- "repo": "flake-compat",
- "type": "github"
- }
- },
- "flake-utils": {
- "inputs": {
- "systems": [
- "systems"
- ]
- },
+ "nixpkgs": {
"locked": {
- "lastModified": 1726560853,
- "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
- "owner": "numtide",
- "repo": "flake-utils",
- "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
+ "lastModified": 1791487292,
+ "narHash": "sha256-tqM7xDEsaeK1p6VvRygeZ9LkQRpUUkbRT02BHv6hnTM=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "8edc0c72e3a38faf5434e40d1f19431125fe4b30",
"type": "github"
},
"original": {
- "owner": "numtide",
- "repo": "flake-utils",
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
"type": "github"
}
},
- "nixpkgs": {
+ "nixpkgs-intel-darwin": {
"locked": {
- "lastModified": 1727653192,
- "narHash": "sha256-YBIXvJOLKU1FIQM0fB5lBLT1CoPpMCn03vHyNsVFXZ4=",
+ "lastModified": 1791447531,
+ "narHash": "sha256-AsoGS8G2y3shwAtQWQhgV4Fa3uw6qzFejfqSblOGkhE=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "c6f2346239c6f6abdc8befcb2a10759083c85f5d",
+ "rev": "55afc4c3adc7194df3b96d43b7d18bf8131bfcfa",
"type": "github"
},
"original": {
"owner": "NixOS",
+ "ref": "nixpkgs-26.05-darwin",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
- "flake-compat": "flake-compat",
- "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
- "systems": "systems"
- }
- },
- "systems": {
- "locked": {
- "lastModified": 1681028828,
- "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
- "owner": "nix-systems",
- "repo": "default",
- "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
- "type": "github"
- },
- "original": {
- "owner": "nix-systems",
- "repo": "default",
- "type": "github"
+ "nixpkgs-intel-darwin": "nixpkgs-intel-darwin"
}
}
},
diff --git a/flake.nix b/flake.nix
index b301e2d..66fe660 100644
--- a/flake.nix
+++ b/flake.nix
@@ -1,97 +1,127 @@
{
- description = "A very simple static Gemini server, now with Titan support!";
-
- inputs = {
- nixpkgs.url = "github:NixOS/nixpkgs";
- systems.url = "github:nix-systems/default";
-
- flake-compat = {
- url = "github:edolstra/flake-compat";
- flake = false;
- };
-
- flake-utils = {
- url = "github:numtide/flake-utils";
- inputs.systems.follows = "systems";
- };
- };
+ description = "A small static Gemini server with optional Titan uploads";
+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+ inputs.nixpkgs-intel-darwin.url = "github:NixOS/nixpkgs/nixpkgs-26.05-darwin";
outputs =
{
nixpkgs,
- flake-utils,
+ nixpkgs-intel-darwin,
self,
- ...
}:
- flake-utils.lib.eachDefaultSystem (
- system:
- let
- pkgs = import nixpkgs { inherit system; };
-
- meta = with pkgs.lib; {
- description = "A very simple static Gemini server, now with Titan support!";
- homepage = "https://github.com/gemrest/maple";
- license = licenses.gpl3Only;
- maintainers = [ maintainers.Fuwn ];
- mainPackage = "maple";
- platforms = platforms.unix;
- };
-
- maple =
- with pkgs;
- (
- if pkgs.stdenv.isDarwin then
- pkgs.clangStdenvNoLibs
- else
- pkgs.stdenvAdapters.useMoldLinker pkgs.clangStdenvNoLibs
- ).mkDerivation
- {
- inherit meta;
-
- name = "maple";
- version = "0.1.6";
- src = lib.cleanSource ./.;
-
- nativeBuildInputs = [
- ninja
- clang
- ];
-
- buildInputs = [
- libressl.dev
+ let
+ forEachSystem = nixpkgs.lib.genAttrs [
+ "aarch64-darwin"
+ "aarch64-linux"
+ "x86_64-darwin"
+ "x86_64-linux"
+ ];
+ packageSets = forEachSystem (
+ system:
+ if system == "x86_64-darwin" then
+ nixpkgs-intel-darwin.legacyPackages.${system}
+ else
+ nixpkgs.legacyPackages.${system}
+ );
+ in
+ {
+ packages = forEachSystem (
+ system:
+ let
+ packageSet = packageSets.${system};
+ maple = packageSet.clangStdenv.mkDerivation {
+ pname = "maple";
+ version = "0.2.0";
+ src = packageSet.lib.fileset.toSource {
+ root = ./.;
+ fileset = packageSet.lib.fileset.unions [
+ ./LICENSE
+ ./build.ninja
+ ./maple
+ ./tests
];
-
- buildPhase = ''
- mkdir -p $out/bin
- ninja
- '';
-
- installPhase = ''
- cp build/maple $out/bin/maple
- '';
};
- in
- {
- packages = {
+ nativeBuildInputs = [
+ packageSet.ninja
+ packageSet.pkg-config
+ ];
+ buildInputs = [ packageSet.openssl ];
+ nativeCheckInputs = [
+ packageSet.python3
+ packageSet.openssl
+ ];
+ doCheck = true;
+
+ buildPhase = ''
+ runHook preBuild
+ ninja
+ runHook postBuild
+ '';
+
+ checkPhase = ''
+ runHook preCheck
+ ninja check
+ runHook postCheck
+ '';
+
+ installPhase = ''
+ runHook preInstall
+ install -Dm755 build/maple $out/bin/maple
+ runHook postInstall
+ '';
+
+ meta = {
+ description = "A small static Gemini server with optional Titan uploads";
+ homepage = "https://github.com/gemrest/maple";
+ license = packageSet.lib.licenses.gpl3Only;
+ maintainers = [ packageSet.lib.maintainers.Fuwn ];
+ mainProgram = "maple";
+ platforms = packageSet.lib.platforms.unix;
+ };
+ };
+ in
+ {
inherit maple;
default = maple;
- };
+ }
+ );
- apps = {
+ apps = forEachSystem (
+ system:
+ let
+ package = self.packages.${system}.maple;
maple = {
- inherit meta;
+ inherit (package) meta;
type = "app";
- program = "${maple}/bin/maple";
+ program = "${package}/bin/maple";
};
+ in
+ {
+ inherit maple;
- default = self.apps.${system}.maple;
- };
+ default = maple;
+ }
+ );
+
+ checks = forEachSystem (system: {
+ inherit (self.packages.${system}) maple;
+ });
+
+ devShells = forEachSystem (
+ system:
+ let
+ packageSet = packageSets.${system};
+ maple = self.packages.${system}.maple;
+ in
+ {
+ default = (packageSet.mkShell.override { stdenv = packageSet.clangStdenv; }) {
+ inputsFrom = [ maple ];
+ };
+ }
+ );
- devShells.default = import ./shell.nix {
- inherit pkgs;
- };
- }
- );
+ formatter = forEachSystem (system: packageSets.${system}.nixfmt);
+ };
}
diff --git a/maple/connection.cc b/maple/connection.cc
new file mode 100644
index 0000000..a95405f
--- /dev/null
+++ b/maple/connection.cc
@@ -0,0 +1,211 @@
+#include "connection.hh"
+#include
+#include
+#include
+#include
+#include
+
+namespace maple {
+auto Connection::can_continue() const -> bool {
+ return !stopping_ && std::chrono::steady_clock::now() < deadline_;
+}
+
+auto Connection::record_progress() -> void {
+ if (transferring_) {
+ deadline_ = std::chrono::steady_clock::now() + transfer_timeout;
+ }
+}
+
+auto Connection::retry(int result) -> bool {
+ constexpr int maximum_poll_interval_milliseconds = 250;
+ const int error = SSL_get_error(tls_session_.get(), result);
+
+ if (error != SSL_ERROR_WANT_READ && error != SSL_ERROR_WANT_WRITE) {
+ return false;
+ }
+
+ while (can_continue()) {
+ const auto remaining = deadline_ - std::chrono::steady_clock::now();
+
+ if (remaining <= std::chrono::steady_clock::duration::zero()) {
+ return false;
+ }
+
+ const auto milliseconds =
+ std::chrono::ceil(remaining);
+ const int timeout_milliseconds = static_cast(
+ std::min(milliseconds,
+ std::chrono::milliseconds(maximum_poll_interval_milliseconds))
+ .count());
+ pollfd descriptor{
+ socket_.get(),
+ static_cast(error == SSL_ERROR_WANT_READ ? POLLIN : POLLOUT), 0};
+ const int ready = poll(&descriptor, 1, timeout_milliseconds);
+
+ if (ready < 0) {
+ return false;
+ }
+
+ // Poll's writability threshold can exceed the space needed for a small write.
+ if (ready == 0 && error == SSL_ERROR_WANT_WRITE) {
+ return can_continue();
+ }
+
+ if (ready > 0) {
+ return can_continue() && (descriptor.revents & descriptor.events) != 0;
+ }
+ }
+
+ return false;
+}
+
+Connection::~Connection() {
+ if (tls_session_ && SSL_is_init_finished(tls_session_.get())) {
+ SSL_shutdown(tls_session_.get());
+ }
+}
+
+auto Connection::handshake() -> bool {
+ const int flags = fcntl(socket_.get(), F_GETFL, 0);
+
+ if (!tls_session_ || flags < 0 ||
+ fcntl(socket_.get(), F_SETFL, flags | O_NONBLOCK) < 0 ||
+ SSL_set_fd(tls_session_.get(), socket_.get()) != 1) {
+ return false;
+ }
+
+ while (can_continue()) {
+ ERR_clear_error();
+
+ const int result = SSL_accept(tls_session_.get());
+
+ if (result == 1) {
+ return true;
+ }
+
+ if (!retry(result)) {
+ return false;
+ }
+ }
+
+ return false;
+}
+
+auto Connection::receive(char *buffer, std::size_t size_bytes,
+ std::size_t &byte_count) -> bool {
+ while (can_continue()) {
+ ERR_clear_error();
+
+ const int result =
+ SSL_read_ex(tls_session_.get(), buffer, size_bytes, &byte_count);
+
+ if (result == 1) {
+ if (byte_count == 0) {
+ return false;
+ }
+
+ record_progress();
+
+ return true;
+ }
+
+ if (!retry(result)) {
+ return false;
+ }
+ }
+
+ return false;
+}
+
+auto Connection::read_header() -> std::optional {
+ constexpr std::size_t maximum_url_bytes = 1024;
+ constexpr std::size_t header_terminator_bytes = 2;
+ std::string header;
+
+ while (header.size() < maximum_url_bytes + header_terminator_bytes) {
+ char character = '\0';
+ std::size_t byte_count = 0;
+
+ if (!receive(&character, 1, byte_count)) {
+ return std::nullopt;
+ }
+
+ if (character == '\n') {
+ if (header.empty() || header.back() != '\r') {
+ return std::nullopt;
+ }
+
+ header.pop_back();
+
+ transferring_ = true;
+
+ record_progress();
+
+ return header;
+ }
+
+ if (character == '\0' || (!header.empty() && header.back() == '\r')) {
+ return std::nullopt;
+ }
+
+ header += character;
+ }
+
+ return std::nullopt;
+}
+
+auto Connection::read_body(std::size_t size_bytes)
+ -> std::optional {
+ constexpr std::size_t buffer_capacity_bytes = 8192;
+ std::string body;
+ std::array buffer{};
+
+ while (body.size() < size_bytes) {
+ std::size_t byte_count = 0;
+ const std::size_t remaining_bytes =
+ std::min(buffer.size(), size_bytes - body.size());
+
+ if (!receive(buffer.data(), remaining_bytes, byte_count)) {
+ return std::nullopt;
+ }
+
+ body.append(buffer.data(), byte_count);
+ }
+
+ return body;
+}
+
+auto Connection::write(std::string_view response) -> bool {
+ while (!response.empty()) {
+ std::size_t byte_count = 0;
+
+ if (!can_continue()) {
+ return false;
+ }
+
+ ERR_clear_error();
+
+ const std::size_t write_bytes =
+ std::min(response.size(), maximum_write_bytes);
+ const int result = SSL_write_ex(tls_session_.get(), response.data(),
+ write_bytes, &byte_count);
+
+ if (result != 1) {
+ if (!retry(result)) {
+ return false;
+ }
+
+ continue;
+ }
+
+ if (byte_count == 0) {
+ return false;
+ }
+
+ response.remove_prefix(byte_count);
+ record_progress();
+ }
+
+ return true;
+}
+} // namespace maple
diff --git a/maple/connection.hh b/maple/connection.hh
new file mode 100644
index 0000000..27c0e1e
--- /dev/null
+++ b/maple/connection.hh
@@ -0,0 +1,47 @@
+#ifndef MAPLE_CONNECTION_HH
+#define MAPLE_CONNECTION_HH
+
+#include "descriptor.hh"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace maple {
+class Connection {
+public:
+ Connection(SSL_CTX *tls_context, int socket,
+ const volatile std::sig_atomic_t &stopping)
+ : socket_(socket), tls_session_(SSL_new(tls_context), SSL_free),
+ stopping_(stopping),
+ deadline_(std::chrono::steady_clock::now() + request_timeout) {}
+
+ ~Connection();
+ auto handshake() -> bool;
+ auto read_header() -> std::optional;
+ auto read_body(std::size_t size_bytes) -> std::optional;
+ auto write(std::string_view response) -> bool;
+
+private:
+ auto can_continue() const -> bool;
+ auto record_progress() -> void;
+ auto retry(int result) -> bool;
+ auto receive(char *buffer, std::size_t size_bytes, std::size_t &byte_count)
+ -> bool;
+
+ static constexpr std::chrono::seconds request_timeout{5};
+ static constexpr std::chrono::seconds transfer_timeout{15};
+ static constexpr std::size_t maximum_write_bytes = 8192;
+ Descriptor socket_;
+ std::unique_ptr tls_session_;
+ const volatile std::sig_atomic_t &stopping_;
+ std::chrono::steady_clock::time_point deadline_;
+ bool transferring_ = false;
+};
+} // namespace maple
+
+#endif
diff --git a/maple/content.cc b/maple/content.cc
new file mode 100644
index 0000000..3d8af2d
--- /dev/null
+++ b/maple/content.cc
@@ -0,0 +1,155 @@
+#include "content.hh"
+#include "uri.hh"
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+using maple::Descriptor;
+
+struct Destination {
+ Descriptor directory;
+ std::string name;
+};
+
+auto resolve(std::string_view path) -> std::optional {
+ if (path.empty() || path.front() != '/' ||
+ path.find('\0') != std::string_view::npos) {
+ return std::nullopt;
+ }
+
+ path.remove_prefix(1);
+
+ Descriptor directory(open(".maple/gmi", O_RDONLY | O_DIRECTORY | O_NOFOLLOW));
+
+ if (directory.get() < 0) {
+ return std::nullopt;
+ }
+
+ for (;;) {
+ const std::size_t separator_offset_bytes = path.find('/');
+ auto component = maple::uri::decode_segment(
+ path.empty() ? "index.gmi" : path.substr(0, separator_offset_bytes));
+
+ if (!component || component->empty() || *component == "." ||
+ *component == "..") {
+ return std::nullopt;
+ }
+
+ if (separator_offset_bytes == std::string::npos) {
+ const std::size_t extension_start_bytes = component->find_last_of('.');
+ std::string extension = extension_start_bytes == std::string::npos
+ ? ""
+ : component->substr(extension_start_bytes);
+
+ std::transform(extension.begin(), extension.end(), extension.begin(),
+ [](unsigned char character) {
+ return static_cast(std::tolower(character));
+ });
+
+ if (extension != ".gmi") {
+ return std::nullopt;
+ }
+
+ return Destination{std::move(directory), std::move(*component)};
+ }
+
+ const int child = openat(directory.get(), component->c_str(),
+ O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
+
+ if (child < 0) {
+ return std::nullopt;
+ }
+
+ directory = Descriptor(child);
+
+ path.remove_prefix(separator_offset_bytes + 1);
+ }
+}
+} // namespace
+
+namespace maple::content {
+auto open_read(std::string_view path) -> Descriptor {
+ auto destination = resolve(path);
+
+ if (!destination) {
+ return Descriptor(-1);
+ }
+
+ Descriptor file(openat(destination->directory.get(),
+ destination->name.c_str(),
+ O_RDONLY | O_NOFOLLOW | O_NONBLOCK));
+ struct stat status{};
+
+ if (file.get() < 0 || fstat(file.get(), &status) < 0 ||
+ !S_ISREG(status.st_mode)) {
+ file = Descriptor(-1);
+ }
+
+ return file;
+}
+
+auto write(std::string_view path, std::string_view body) -> bool {
+ auto destination = resolve(path);
+
+ if (!destination) {
+ return false;
+ }
+
+ struct stat status{};
+ const int existing =
+ fstatat(destination->directory.get(), destination->name.c_str(), &status,
+ AT_SYMLINK_NOFOLLOW);
+
+ if ((existing == 0 && !S_ISREG(status.st_mode)) ||
+ (existing < 0 && errno != ENOENT)) {
+ return false;
+ }
+
+ static unsigned long sequence = 0;
+ const std::string temporary_name = ".maple-upload-" +
+ std::to_string(getpid()) + "-" +
+ std::to_string(sequence++);
+ Descriptor file(openat(destination->directory.get(), temporary_name.c_str(),
+ O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600));
+
+ if (file.get() < 0) {
+ return false;
+ }
+
+ bool complete = true;
+
+ while (!body.empty()) {
+ const ssize_t byte_count = ::write(file.get(), body.data(), body.size());
+
+ if (byte_count < 0 && errno == EINTR) {
+ continue;
+ }
+
+ if (byte_count <= 0) {
+ complete = false;
+
+ break;
+ }
+
+ body.remove_prefix(static_cast(byte_count));
+ }
+
+ complete = complete && fsync(file.get()) == 0;
+
+ if (complete) {
+ complete =
+ renameat(destination->directory.get(), temporary_name.c_str(),
+ destination->directory.get(), destination->name.c_str()) == 0;
+ }
+
+ if (!complete) {
+ unlinkat(destination->directory.get(), temporary_name.c_str(), 0);
+ }
+
+ return complete;
+}
+} // namespace maple::content
diff --git a/maple/content.hh b/maple/content.hh
new file mode 100644
index 0000000..b6b3f94
--- /dev/null
+++ b/maple/content.hh
@@ -0,0 +1,14 @@
+#ifndef MAPLE_CONTENT_HH
+#define MAPLE_CONTENT_HH
+
+#include "descriptor.hh"
+#include
+#include
+#include
+
+namespace maple::content {
+auto open_read(std::string_view path) -> Descriptor;
+auto write(std::string_view path, std::string_view body) -> bool;
+} // namespace maple::content
+
+#endif
diff --git a/maple/descriptor.hh b/maple/descriptor.hh
new file mode 100644
index 0000000..a37f5a7
--- /dev/null
+++ b/maple/descriptor.hh
@@ -0,0 +1,43 @@
+#ifndef MAPLE_DESCRIPTOR_HH
+#define MAPLE_DESCRIPTOR_HH
+
+#include
+#include
+
+namespace maple {
+class Descriptor {
+public:
+ explicit Descriptor(int value) : value_(value) {}
+
+ Descriptor(const Descriptor &) = delete;
+ auto operator=(const Descriptor &) -> Descriptor & = delete;
+
+ Descriptor(Descriptor &&other) noexcept
+ : value_(std::exchange(other.value_, -1)) {}
+
+ auto operator=(Descriptor &&other) noexcept -> Descriptor & {
+ if (this != &other) {
+ if (value_ >= 0) {
+ close(value_);
+ }
+
+ value_ = std::exchange(other.value_, -1);
+ }
+
+ return *this;
+ }
+
+ ~Descriptor() {
+ if (value_ >= 0) {
+ close(value_);
+ }
+ }
+
+ auto get() const -> int { return value_; }
+
+private:
+ int value_;
+};
+} // namespace maple
+
+#endif
diff --git a/maple/gemini.cc b/maple/gemini.cc
index 010e844..08ff1ac 100644
--- a/maple/gemini.cc
+++ b/maple/gemini.cc
@@ -18,34 +18,45 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
-#include
-#include
-#include
-#include
-#include
-#include
-
#include "gemini.hh"
+#include "content.hh"
+#include
+#include
namespace maple::gemini {
-auto handle_client(const std::vector &gemini_files,
- std::string path, std::stringstream &response) -> void {
- // Check if the route is a file being served
- if (std::find(gemini_files.begin(), gemini_files.end(),
- ".maple/gmi" + path) != gemini_files.end()) {
- // If the route is a file being served; get the file contents
- response << "20 text/gemini\r\n"
- << std::ifstream(".maple/gmi" + path).rdbuf();
- } else {
- if (path.empty() || path.at(path.length() - 1) == '/') {
- response << "20 text/gemini\r\n"
- << std::ifstream(".maple/gmi" + path + "index.gmi").rdbuf();
- } else {
- response
- << "51 The server (Maple) could not find the specified file.\r\n";
- }
+auto handle_client(Connection &connection, std::string_view path) -> bool {
+ const auto file = content::open_read(path);
+
+ if (file.get() < 0) {
+ return connection.write("51 File not found.\r\n");
+ }
+
+ if (!connection.write("20 text/gemini\r\n")) {
+ return false;
}
- std::cout << "requested " << path << '\n';
+ constexpr std::size_t buffer_capacity_bytes = 8192;
+ std::array buffer{};
+
+ for (;;) {
+ const ssize_t byte_count = ::read(file.get(), buffer.data(), buffer.size());
+
+ if (byte_count < 0 && errno == EINTR) {
+ continue;
+ }
+
+ if (byte_count < 0) {
+ return false;
+ }
+
+ if (byte_count == 0) {
+ return true;
+ }
+
+ if (!connection.write(std::string_view(
+ buffer.data(), static_cast(byte_count)))) {
+ return false;
+ }
+ }
}
} // namespace maple::gemini
diff --git a/maple/gemini.hh b/maple/gemini.hh
index 8a7796c..2ddc078 100644
--- a/maple/gemini.hh
+++ b/maple/gemini.hh
@@ -21,13 +21,12 @@
#ifndef GEMINI_HH
#define GEMINI_HH
-#include
-#include
+#include "connection.hh"
+#include
namespace maple {
namespace gemini {
-auto handle_client(const std::vector &, std::string,
- std::stringstream &) -> void;
+auto handle_client(Connection &connection, std::string_view path) -> bool;
}
} // namespace maple
diff --git a/maple/maple.cc b/maple/maple.cc
index 426cc14..dea6944 100644
--- a/maple/maple.cc
+++ b/maple/maple.cc
@@ -18,314 +18,247 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
+#include "connection.hh"
+#include "content.hh"
+#include "gemini.hh"
+#include "size.hh"
+#include "titan.hh"
+#include "uri.hh"
#include
-#include
+#include
#include
#include
-#include
+#include
#include
#include
-#include
+#include
#include
+#include
+#include
#include
#include
-#include
-#include
-#include
+#include
#include
-#include
-#include
-#if !defined(__APPLE__)
-#include
-#endif
+namespace {
+volatile std::sig_atomic_t stopping = 0;
-#include "gemini.hh"
-#include "maple.hh"
-#include "titan.hh"
+struct Configuration {
+ std::uint16_t port = 1965;
+ bool titan_enabled = false;
+ std::string token;
+ std::size_t maximum_upload_bytes = 1024;
+};
-auto main() -> int {
- std::vector gemini_files;
- bool titan = false;
- std::string titan_token;
- std::size_t titan_max_size = 0;
- const std::string GEMINI_FILE_EXTENSION = "gmi";
-
- // Check if the user is want to support Titan and set it up
- if (maple::setup_environment(titan, titan_token, titan_max_size) == 1) {
- return EXIT_FAILURE;
- }
+auto configuration() -> std::optional {
+ Configuration settings;
+
+ if (const char *configured_port = std::getenv("MAPLE_PORT")) {
+ const auto port = maple::parse_size(configured_port);
- // Try a graceful shutdown when a SIGINT is detected
- signal(SIGINT, [](int _signal) -> void {
- std::cout << "shutdown(" << _signal << ")\n";
-
- close(maple::maple_socket);
- SSL_CTX_free(maple::ssl_context);
- });
- signal(SIGPIPE, SIG_IGN);
-
- // Find and keep track of all Gemini files to serve
- for (const std::filesystem::directory_entry &entry :
- std::filesystem::recursive_directory_iterator(".maple/gmi")) {
- std::string file_extension = entry.path().string().substr(
- entry.path().string().find_last_of('.') + 1);
-
- // Only keep track of file if it is a Gemini file
- if (std::equal(file_extension.begin(), file_extension.end(),
- GEMINI_FILE_EXTENSION.begin(), GEMINI_FILE_EXTENSION.end(),
- [](char character_a, char character_b) -> bool {
- return std::tolower(character_a) ==
- std::tolower(character_b);
- })) {
- gemini_files.push_back(entry.path());
+ if (!port || *port == 0 || *port > std::numeric_limits::max()) {
+ std::cerr << "MAPLE_PORT must be an unsigned integer between 1 and 65535.\n";
+
+ return std::nullopt;
}
+
+ settings.port = static_cast(*port);
}
- // Inform user of which files will be served
- for (const std::string &file : gemini_files) {
- std::cout << "serving " << file << '\n';
+ const char *enabled = std::getenv("TITAN");
+
+ if (enabled == nullptr) {
+ return settings;
}
- // Setup SSL
- if (maple::setup_ssl() == EXIT_FAILURE) {
- return EXIT_FAILURE;
+ std::string value(enabled);
+
+ std::transform(value.begin(), value.end(), value.begin(),
+ [](unsigned char character) {
+ return static_cast(std::tolower(character));
+ });
+
+ settings.titan_enabled = value == "1" || value == "true";
+
+ if (!settings.titan_enabled) {
+ return settings;
}
- // Listen and serve connections
- for (;;) {
- sockaddr_in socket_address_{};
- unsigned int socket_address_length = sizeof(socket_address_);
- SSL *ssl;
- const int client = accept(maple::maple_socket,
- reinterpret_cast(&socket_address_),
- &socket_address_length);
+ if (const char *token = std::getenv("TITAN_TOKEN")) {
+ settings.token = token;
+ }
- if (client < 0) {
- return maple::prepare_exit_with("unable to accept", false);
- }
+ if (const char *configured_maximum_upload_bytes =
+ std::getenv("TITAN_MAX_SIZE")) {
+ const auto parsed_maximum_upload_bytes =
+ maple::parse_size(configured_maximum_upload_bytes);
- ssl = SSL_new(maple::ssl_context);
-
- SSL_set_fd(ssl, client);
-
- if (SSL_accept(ssl) <= 0) {
- ERR_print_errors_fp(stderr);
- } else {
- std::stringstream response;
- std::size_t index_of_junk;
- int request_scheme; // Gemini = 1, Titan = 2, Error = 0
- std::size_t bytes_read = 0;
- constexpr std::size_t GEMINI_MAXIMUM_REQUEST_SIZE = 1024;
- std::array request{};
- const int read_result =
- SSL_read_ex(ssl, request.begin(), request.size(), &bytes_read);
-
- if (read_result > 0 && bytes_read > 0) {
- std::string path(request.data(), bytes_read);
-
- if (path.starts_with("gemini://")) {
- request_scheme = 1;
- } else if (path.starts_with("titan://")) {
- request_scheme = 2;
- } else {
- request_scheme = 0;
- }
-
- if (request_scheme != 0) {
- bool request_valid = true;
-
- // Remove "\r\n" if Gemini
- if (request_scheme == 1) {
- if (path.size() < 2) {
- std::cout << "received malformed gemini request\n";
-
- request_valid = false;
- } else {
- path.resize(path.size() - 2);
- }
- }
-
- if (request_valid) {
- if (request_scheme == 1) {
- constexpr std::size_t GEMINI_PROTOCOL_LENGTH = 9;
-
- path.erase(0, GEMINI_PROTOCOL_LENGTH); // Remove "gemini://"
- } else {
- constexpr std::size_t TITAN_PROTOCOL_LENGTH = 8;
-
- path.erase(0, TITAN_PROTOCOL_LENGTH); // Remove "titan://"
- }
-
- // Try to remove the host, if you cannot; it must be a trailing
- // slash-less hostname, so we will respond with the index.
- const std::size_t found_first = path.find_first_of('/');
-
- if (found_first != std::string::npos) {
- path = path.substr(found_first,
- path.size() - 1); // Remove host
- } else {
- path = "/index.gmi";
- }
-
- if (request_scheme == 1) {
- // Remove junk, if any
- index_of_junk = path.find_first_of('\n');
-
- if (index_of_junk != std::string::npos) {
- path.erase(path.find_first_of('\n') - 1, path.size() - 1);
- }
- }
-
- // Gemini
- if (request_scheme == 1) {
- maple::gemini::handle_client(gemini_files, path, response);
- } else { // Titan
- if (!titan) {
- response
- << "20 text/gemini\r\nThe server (Maple) does not have "
- "Titan support enabled!";
- } else {
- maple::titan::handle_client(response, path, titan_token,
- titan_max_size);
- }
- }
-
- const std::string response_string = response.str();
- const int bytes_written =
- SSL_write(ssl, response_string.c_str(),
- static_cast(response_string.size()));
-
- if (bytes_written <= 0) {
- ERR_print_errors_fp(stderr);
- }
- }
- } else {
- std::cout << "received a request with an unsupported url scheme\n";
- }
- } else {
- ERR_print_errors_fp(stderr);
- }
+ if (!parsed_maximum_upload_bytes) {
+ std::cerr << "TITAN_MAX_SIZE must be an unsigned integer.\n";
+
+ return std::nullopt;
}
- SSL_shutdown(ssl);
- SSL_free(ssl);
- close(client);
+ settings.maximum_upload_bytes = *parsed_maximum_upload_bytes;
}
+
+ return settings;
}
-namespace maple {
-auto prepare_exit_with(const char *message, bool ssl) -> int {
- perror(message);
+auto serve_request(maple::Connection &connection, bool titan_enabled,
+ const std::string &token, std::size_t maximum_upload_bytes)
+ -> bool {
+ const auto header = connection.read_header();
- if (ssl) {
- ERR_print_errors_fp(stderr);
+ if (!header) {
+ return connection.write("59 Invalid request header.\r\n");
}
- return EXIT_FAILURE;
-}
-
-auto setup_environment(bool &titan, std::string &titan_token,
- std::size_t &titan_max_size) -> int {
- char *titan_environment = std::getenv("TITAN");
+ const auto request = maple::uri::parse_request(*header);
- if (titan_environment == nullptr) {
- titan = false;
- } else {
- std::string valid_titan_environment(titan_environment);
+ if (!request) {
+ return connection.write("59 Invalid URL.\r\n");
+ }
- std::transform(
- valid_titan_environment.begin(), valid_titan_environment.end(),
- valid_titan_environment.begin(),
- [](unsigned char character) -> int { return std::tolower(character); });
+ if (!request->titan) {
+ return maple::gemini::handle_client(connection, request->path);
+ }
- if (valid_titan_environment == "true" || valid_titan_environment == "1") {
- char *unvalidated_titan_token = std::getenv("TITAN_TOKEN");
- char *unvalidated_titan_max_size = std::getenv("TITAN_MAX_SIZE");
+ if (!titan_enabled) {
+ return connection.write("59 Titan is disabled.\r\n");
+ }
- if (unvalidated_titan_token == nullptr) {
- titan_token = "";
- } else {
- titan_token = std::string(unvalidated_titan_token);
- }
+ const auto upload =
+ maple::titan::parse_upload(request->path, token, maximum_upload_bytes);
- if (unvalidated_titan_max_size == nullptr) {
- constexpr std::size_t TITAN_MAX_SIZE = 1024;
+ if (!upload) {
+ return connection.write("59 Invalid Titan parameters or token.\r\n");
+ }
- titan_max_size = TITAN_MAX_SIZE;
+ const auto body = connection.read_body(upload->size_bytes);
- std::cout << "no TITAN_MAX_SIZE set, defaulting to 1024\n";
- } else {
- try {
- titan_max_size =
- static_cast(std::stoi(unvalidated_titan_max_size));
- } catch (...) {
- return maple::prepare_exit_with(
- "TITAN_MAX_SIZE could not be interpreted as an integer", false);
- }
- }
+ if (!body) {
+ return connection.write("59 Incomplete upload.\r\n");
+ }
- titan = true;
- }
+ if (!maple::content::write(upload->path, *body)) {
+ return connection.write("50 Unable to write content.\r\n");
}
- return 0;
+ return connection.write("20 text/gemini\r\nUpload complete.\n");
}
-auto setup_ssl() -> int {
- sockaddr_in socket_address{};
+auto listen_socket(std::uint16_t port) -> maple::Descriptor {
+ maple::Descriptor listener(socket(AF_INET, SOCK_STREAM, 0));
+ sockaddr_in address{};
+ const int reuse_address = 1;
+
+ address.sin_family = AF_INET;
+ address.sin_port = htons(port);
+ address.sin_addr.s_addr = htonl(INADDR_ANY);
+
+ if (listener.get() < 0 ||
+ setsockopt(listener.get(), SOL_SOCKET, SO_REUSEADDR, &reuse_address,
+ sizeof(reuse_address)) < 0 ||
+ fcntl(listener.get(), F_SETFL, O_NONBLOCK) < 0 ||
+ bind(listener.get(), reinterpret_cast(&address),
+ sizeof(address)) < 0 ||
+ listen(listener.get(), SOMAXCONN) < 0) {
+ perror("Unable to listen on the configured port");
+
+ listener = maple::Descriptor(-1);
+ }
- // Setup OpenSSL
- SSL_library_init();
- SSL_load_error_strings();
+ return listener;
+}
+} // namespace
- maple::ssl_context = SSL_CTX_new(TLS_server_method());
+auto main() -> int {
+ const auto settings = configuration();
- if (maple::ssl_context == nullptr) {
- return maple::prepare_exit_with("unable to create ssl context", true);
+ if (!settings) {
+ return EXIT_FAILURE;
}
- if (SSL_CTX_use_certificate_file(maple::ssl_context, ".maple/public.pem",
- SSL_FILETYPE_PEM) <= 0) {
- return maple::prepare_exit_with("unable to use certificate file", true);
- }
+ struct sigaction action{};
- if (SSL_CTX_use_PrivateKey_file(maple::ssl_context, ".maple/private.pem",
- SSL_FILETYPE_PEM) <= 0) {
- return maple::prepare_exit_with("unable to use private key file", true);
- }
+ action.sa_handler = [](int) { stopping = 1; };
+
+ sigemptyset(&action.sa_mask);
- constexpr std::size_t GEMINI_PORT = 1965;
+ if (sigaction(SIGINT, &action, nullptr) < 0 ||
+ sigaction(SIGTERM, &action, nullptr) < 0) {
+ perror("Unable to install shutdown handlers");
- socket_address.sin_family = AF_INET;
- socket_address.sin_port = htons(GEMINI_PORT);
- socket_address.sin_addr.s_addr = htonl(INADDR_ANY);
+ return EXIT_FAILURE;
+ }
- maple::maple_socket = socket(AF_INET, SOCK_STREAM, 0);
+ action.sa_handler = SIG_IGN;
- if (maple::maple_socket < 0) {
- return maple::prepare_exit_with("unable to create socket", false);
+ if (sigaction(SIGPIPE, &action, nullptr) < 0) {
+ perror("Unable to ignore SIGPIPE");
+
+ return EXIT_FAILURE;
}
- // Reuse address. Allows the use of the address instantly after a SIGINT
- // without having to wait for the socket to die.
- int reuse_addr = 1;
+ const std::unique_ptr tls_context(
+ SSL_CTX_new(TLS_server_method()), SSL_CTX_free);
+
+ if (!tls_context ||
+ SSL_CTX_use_certificate_file(tls_context.get(), ".maple/public.pem",
+ SSL_FILETYPE_PEM) != 1 ||
+ SSL_CTX_use_PrivateKey_file(tls_context.get(), ".maple/private.pem",
+ SSL_FILETYPE_PEM) != 1 ||
+ SSL_CTX_check_private_key(tls_context.get()) != 1) {
+ std::cerr
+ << "Unable to load the server certificate and private key from .maple.\n";
+ ERR_print_errors_fp(stderr);
- if (setsockopt(maple::maple_socket, SOL_SOCKET, SO_REUSEADDR, &reuse_addr,
- sizeof(int)) < 0) {
- return maple::prepare_exit_with("unable to set socket options (SO_LINGER)",
- false);
+ return EXIT_FAILURE;
}
- if (bind(maple::maple_socket, reinterpret_cast(&socket_address),
- sizeof(socket_address)) < 0) {
- return maple::prepare_exit_with("unable to bind", false);
+ const auto listener = listen_socket(settings->port);
+
+ if (listener.get() < 0) {
+ return EXIT_FAILURE;
}
- if (listen(maple::maple_socket, 1) < 0) {
- return maple::prepare_exit_with("unable to listen", false);
+ while (!stopping) {
+ constexpr int connection_poll_interval_milliseconds = 250;
+ pollfd descriptor{listener.get(), POLLIN, 0};
+ const int ready =
+ poll(&descriptor, 1, connection_poll_interval_milliseconds);
+
+ if (ready < 0 && errno != EINTR) {
+ perror("Unable to wait for a connection");
+
+ return EXIT_FAILURE;
+ }
+
+ if (ready <= 0 || stopping) {
+ continue;
+ }
+
+ const int client = accept(listener.get(), nullptr, nullptr);
+
+ if (client < 0) {
+ if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR ||
+ errno == ECONNABORTED) {
+ continue;
+ }
+
+ perror("Unable to accept a connection");
+
+ return EXIT_FAILURE;
+ }
+
+ maple::Connection connection(tls_context.get(), client, stopping);
+
+ if (connection.handshake()) {
+ serve_request(connection, settings->titan_enabled, settings->token,
+ settings->maximum_upload_bytes);
+ }
}
- return 0;
+ return EXIT_SUCCESS;
}
-} // namespace maple
diff --git a/maple/maple.hh b/maple/maple.hh
deleted file mode 100644
index 0ae6a2d..0000000
--- a/maple/maple.hh
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * This file is part of Maple .
- * Copyright (C) 2022-2022 Fuwn
- *
- * 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 .
- *
- * Copyright (C) 2022-2022 Fuwn
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-#ifndef MAPLE_HH
-#define MAPLE_HH
-
-#include
-#include
-
-namespace maple {
-static int maple_socket;
-static SSL_CTX *ssl_context;
-
-auto prepare_exit_with(const char *, bool) -> int;
-auto setup_environment(bool &, std::string &, std::size_t &) -> int;
-auto setup_ssl() -> int;
-} // namespace maple
-
-#endif // MAPLE_HH
diff --git a/maple/size.hh b/maple/size.hh
new file mode 100644
index 0000000..f1411dd
--- /dev/null
+++ b/maple/size.hh
@@ -0,0 +1,27 @@
+#ifndef MAPLE_SIZE_HH
+#define MAPLE_SIZE_HH
+
+#include
+#include
+#include
+#include
+
+namespace maple {
+inline auto parse_size(std::string_view text) -> std::optional {
+ if (text.empty()) {
+ return std::nullopt;
+ }
+
+ std::size_t value = 0;
+ const auto [parsed_end, error] =
+ std::from_chars(text.begin(), text.end(), value);
+
+ if (error != std::errc{} || parsed_end != text.end()) {
+ return std::nullopt;
+ }
+
+ return value;
+}
+} // namespace maple
+
+#endif
diff --git a/maple/titan.cc b/maple/titan.cc
index 2021903..5036879 100644
--- a/maple/titan.cc
+++ b/maple/titan.cc
@@ -18,153 +18,71 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
-#include
-#include
-#include