#gemini #client #experimental #apps #fun #aims #gem-bytes

fluffer

Fluffer 🦊 is an experimental crate that aims to make writing Gemini apps fun and easy

17 releases (4 major breaking)

2023.10.27 Oct 27, 2023
4.0.0 Feb 29, 2024
3.0.0 Feb 28, 2024
2.0.0 Dec 7, 2023
0.9.1 Nov 23, 2023

#1 in #fun

Download history 16/week @ 2024-02-18 297/week @ 2024-02-25 33/week @ 2024-03-03 9/week @ 2024-03-10 142/week @ 2024-03-31

487 downloads per month
Used in dovetail

GPL-3.0-only

43KB
809 lines

🦊 Fluffer

Fluffer is an experimental crate that aims to make writing Gemini apps fun and easy.

Other helpful gemini projects:

🗼 Design

Similar to Axum, Fluffer routes are generic functions that can return anything that implements the GemBytes trait.

There are some helpful implementations out of the box, so please consult GemBytes and Fluff while you experiment.

Also, this crate has a lot of examples for you to check out. Including a dice roller app.

Here is a basic example of a Fluffer app.

use fluffer::{App, Fluff};

#[tokio::main]
async fn main() {
    App::default()
        .route("/", |_| async {
            "# Welcome\n=> /u32 Should show a number\n=> /pic 🦊 Here's a cool picture!"
        })
        .route("/u32", |_| async { 777 })
        .route("/pic", |_| async { Fluff::File("picture.png".to_string()) })
        .run()
        .await;
}

💎 GemBytes

The GemBytes trait returns a Gemini byte response, which is formatted like this:

<STATUS><SPACE><META>\r\n<CONTENT>

Note: you must include the <SPACE> character, even if <META> is blank.

To implement GemBytes on a type is to decide which Gemini response is appropriate for it.

For example: it is sensible to represent some mime-ambiguous data as a successful Gemtext response so it can be read in a client.

use fluffer::{GemBytes, async_trait};

struct Profile {
    name: String,
    bio: String,
}

#[async_trait]
impl GemBytes for Profile {
    async fn gem_bytes(&self) -> Vec<u8> {
        format!("20 text/gemini\r\n# {},\n{}", self.name, self.bio).into_bytes()
    }
}

📜 Client

Gemini uses client certificates to facilitate identities in geminispace.

Client provides methods that correspond to common identity practices in Gemini.

Function Descripion
Client::certificate Get the client cert in the PEM format.
Client::fingerprint Get the client cert's fingerprint (SHA-256).
Client::name Get the client cert's subject_name field. Useful for providing temporary usernames, or just saying hello.
Client::verify Verify that this client's cert is one you're expecting.

🥴 Input, queries, and parameters

When a gemini client is prompted for input, that input consumes the entire query line.

As such, you should not attach query information to a route that also prompts the user.

Keep this in mind when considering the following options.

Input

To get a user's input to a route, call Client::input. This returns the whole query line percent-decoded.

App::default()
    .route("/" |c| async {
        c.input().unwrap_or("no input 😥".to_string())
    })
    .run()
    .await
    .unwrap()

Queries

For routes where you aren't also accounting for a user's input, queries are suitable for tracking UI state across requests.

For example, you can add warning or error messages to a gemtext document by redirecting to a path with special query names. (E.g. /home?err=bad%20thingg%20happened),

The Fluff variant Fluff::RedirectQueries helps you to do this by redirecting to a route with a vector of key-value queries.

You can persist multiple queries by including them in a gemtext link to other pages (or the same page).

Use the method Client::query to look for query values which correspond to a key.

Parameters

Parameters are derived from patterns you define on a route's path.

To use a parameter, define it in your route's path string, and call Client::parameter in the route method.

App::default()
    .route("/page=:number" |c| async {
        format!("{}", c.parameter("number").unwrap_or("0"))
    })
    .run()
    .await
    .unwrap()

Fluffer uses matchit. If you're unfamiliar, here's a couple of examples:

  • "/owo/:a/:b" defines parameters a and b, e.g: /owo/thisisa/thisisb
  • "/page=:n/filter=:f defines the parameter n, and f following a prefix, e.g: /page=20/filter=date.

Things to keep in mind:

  • Some gemini clients cache pages based on their route. So, you may not want to use parameters for routes that update frequently.
  • Every parameter must be included in your url for the route to be found.
  • Be careful where you define your parameters. It's possible to consume requests intended for a different route.
  • It's more flexible to represent complex expressions as a single parameter, which you parse manually inside the route function.

🌕 Titan

Titan is a sister-protocol to gemini. It allows clients to send data to the server (à la http post). (read more)

This allows titan clients to upload images, videos, and large amounts of text.

To use titan, you must selectively decide which routes will accept titan data (and how much). You do this by calling App::titan instead of App::route.

Once you've done that, the titan property on Client will yield a titan resource if the request was made with titan.

Here's an example of all that in action.

use fluffer::{App, Client};

async fn index(c: Client) -> String {
    if let Some(titan) = c.titan {
        return format!(
            "Size: {}\nMime: {}\nContent: {}\nToken: {}",
            titan.size,
            titan.mime,
            std::str::from_utf8(&titan.content).unwrap_or("[not utf8]"),
            titan.token.unwrap_or(String::from("[no token]")),
        );
    }

    format!(
        "Hello, I'm expecting a text/plain gemini request.\n=> titan://{} Click me",
        c.url.domain().unwrap_or("")
    )
}

#[tokio::main]
async fn main() {
    App::default()
        .titan("/", index, 20_000_000) // < limits content size to 20mb
        .run()
        .await
        .unwrap()
}

🏃 App State

Currently, Fluffer allows you to add one piece of state that gets attached as a generic to Client.

This means you'll need to reflect the app's state in every reference of Client, so I recommend using a type alias.

use fluffer::App;
use std::sync::{Arc, Mutex};

// Type alias for Client<State> **highly recommended**
type Client = fluffer::Client<Arc<Mutex<State>>>;

#[derive(Default)]
struct State {
    visitors: u32,
}

async fn index(c: Client) -> String {
    let mut state = c.state.lock().unwrap();
    state.visitors += 1;

    format!("Visitors: {}", state.visitors)
}

#[tokio::main]
async fn main() {
    let state = Arc::new(Mutex::new(State::default()));

    App::default()
        .state(state) // <- Must be called first.
        .route("/", index)
        .run()
        .await
        .unwrap()
}

✨ Features

Name Description Default
interactive Enables interactive prompt for generating key/cert at runtime. Yes
anyhow Enables the GemBytes impl for anyhow (not recommended outside of debugging) No
reqwest Enables the GemBytes impl for reqwest::Result and reqwest::Response No

📚 Helpful Resources

📋 Todo

  • Async for route functions
  • Switch to openssl
  • Add peer certificate to client
  • Spawn threads
  • App data
  • Titan support
  • Add more options to certificate generation
  • Tests..?

Dependencies

~9–24MB
~394K SLoC