Building Dynamic Gemini Capsules with Go!

Table of Contents

Building Dynamic Gemini Capsules with Go!

By Tre Babcock at May 31, 2021

Gemini is, by design, simple. No styles, no scripting, and almost no interactivity. But we can use what little interactivity we’re given, links and the input status, along with some serverside scripting to make some interesting content!

In this article, we’re going to make a simple textboard with posts and comments. For the sake of simplicity, all posts and comments will be anonymous.

Requirements

Software:

  • Go 1.14+

Go packages:

  • github.com/google/uuid v1.2.0
  • github.com/pitr/gig v0.9.8
  • gorm.io/driver/sqlite v1.1.4
  • gorm.io/gorm v1.21.10

Setting Up The Project

mkdir textboard
cd textboard/
go mod init textboard

This will create our project folder, and initialize a Go module.

mkdir app/
mkdir app/handler
mkdir app/model
touch main.go
touch app/app.go
touch app/handler/posts.go
touch app/model/post.go
touch app/model/model.go

I know this seems unnecessarily complicated, but it’s a good project structure that will help the project scale.

Let’s Write Some Code

Let’s start with app/model/post.go to set up the models.

package model

// Post is our post model
type Post struct {
    Content     string      // the post's contents
    ID          string      // the post's ID (a UUID)
    Time        string      // the post's creation time
    Comments    []Comment   // the post's comments
}

type Comment struct {
    Content     string      // the comment's contents
    ID          string      // the comment's ID (also a UUID)
    Time        string      // the comment's creation time
    PostID      string      // the ID of the post for this comment
}

That’s all for this file. Next let’s open up app/model/model.go to setup the database migration function.

package model

import (
    _ "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// DBMigrate will setup the database tables for the specified models and return a database object
func DBMigrate(db *gorm.DB) *gorm.DB {
    db.AutoMigrate(&Post{}, &Comment{})
    return db
}

This file is also done now. Now we can open up app/app.go and setup our app!

package app

import (
    "fmt"
    "log"
    "textboard/app/model"
    "textboard/app/handler"
    
    "github.com/pitr/gig" 
    "gorm.io/driver/sqlite"
    "gorm.io/gorm"
)

// App holds our router and database
type App struct {
    Router  *gig.Gig
    DB      *gorm.DB
}

// baseURL is a helper function to return the full url of a path
func baseURL(route string) string {
    return fmt.Sprintf("gemini://localhost%s", route)
}

func (a *App) Init() {
    // Open the database, or create it if it doesn't exist
    db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{})
    if err != nil {
        log.Fatal(err)
    }
    a.DB = model.DBMigrate(db)
    a.Router = gig.Default()
    
    a.setRoutes() // we'll get to this soon
}

Now that we have a basic server setup, we can begin adding dynamic routes. Let’s think for a minute about what routes we’ll need. We’ll have an index page that will show all the posts. We’ll have a post page that shows a specified post and its comments. And then we’ll need routes for sending posts and replies. Something like this:

  • /
  • post:id
  • /createpost
  • createcomment:postId

Easy enough. Now let’s write some more code! Open up app/app.go again.

// See, I told you we'd come back to it. This function will setup our routes with a handler function
func (a *App) setRoutes() {
    a.handle("/", a.index)
    a.handle("/post/:id", a.post)
    a.handle("/createpost", a.createPost)
    a.handle("/createcomment/:postId", a.createComment)
}

// This function takes in a route and a handler function, as seen above, then tells gig to handle it
func (a *App) handle(path string, f func(c gig.Context) error) {
    a.Router.Handle(path, f)
}

// Index handler
func (a *App) index(c gig.Context) error {
    
}

// Post handler
func (a *App) post(c gig.Context) error {
    
}

// Create post handler
func (a *App) createPost(c gig.Context) error {
    
}

// Create comment handler
func (a *App) createComment(c gig.Context) error {
    
}

Before we can setup these handlers, we need to write some other handlers! Open up app/handler/posts.go so we can get that out of the way.

package handler

import (
    "textboard/app/model"
    "time"
    "github.com/google/uuid"
    "gorm.io/gorm"
)

// CreatePost will create a new post and add it to the database, then return the post's ID
func CreatePost(db *gorm.DB, content string) string {
    id := uuid.NewString()
    
    post := model.Post{
        Content: content,
        ID:      id,
        Time:    time.Now().UTC().Format(time.Stamp),
    }
    
    // This bit of code saves the post, and the code in the brackets only runs if there's an error
    if err := db.Create(&post).Error; err != nil {
        return "error"
    }
    return id
}

// GetPost will look in the database for a post with the specified ID, then return that post
func GetPost(db *gorm.DB, id string) *model.Post {
    post := model.Post{}
    if err := db.First(&post, db.Where(model.Post{ID: id})).Error; err != nil {
        return nil
    }
    return &post
}

// GetAllPosts will return all posts from the database
func GetAllPosts(db *gorm.DB) []model.Post {
    posts := []model.Post{}
    db.Find(&posts)
    return posts
}

// AddComment will create a new comment object and then add it to the Comments slice in the specified post
func AddComment(db *gorm.DB, content, postID string) {
    post := GetPost(db, postID)
    id := uuid.NewString()
    c := model.Comment{
        Content: content,
        ID:      id,
        Time:    time.Now().UTC().Format(time.Stamp),
        PostID:  postID,
    }
    post.Comments = append(post.Comments, c)
    if err := db.Save(&post).Error; err != nil {
        return
    }
}

// GetComments returns the comments for the specified post
func GetComments(db *gorm.DB, postID string) []model.Comment {
    comments := []model.Comment{}
    
    // This will look in the database for comments with a matching PostID
    db.Find(&comments, db.Where(model.Comment{PostID: postID}))
    return comments
}

Okay, we’re done with that file now. We’re getting close! We can now finish setting up the route handlers in app/app.go.

// Index handler
func (a *App) index(c gig.Context) error {
    // let's setup a string that will hold all of our gemtext
    buffer := ""
    
    // this is going to look pretty ugly, but it gets the job done
    buffer += "# Gemini Textboard\n"
    buffer += "\n"
    buffer += fmt.Sprintf("=> %s %s\n", baseURL("/createpost"), "Create Post")
    buffer += "\n"
    buffer += "## Posts\n"
    buffer += "\n"
    
    // you see how handy those handlers are?
    for _, post := range handler.GetAllPosts(a.DB) {
        buffer += fmt.Sprintf("=> %s %s\n", baseURL(fmt.Sprintf("/post/%s", post.ID)), post.ID)
        buffer += (post.Time + "\n")
        buffer += fmt.Sprintf("> %s\n", post.Content)
        buffer += "\n"
    }
    
    // this sends the gemtext back to the client
    return c.Gemini(buffer)
}

// Post handler
func (a *App) post(c gig.Context) error {
    // this will get the post object for the this route
    post := handler.GetPost(a.DB, c.Param("id"))
    
    buffer := ""
    buffer += "# Post\n"
    buffer += fmt.Sprintf("=> %s %s\n", baseURL("/"), "Home")
    buffer += "\n"
    buffer += (post.ID + "\n")
    buffer += (post.Time + " UTC\n")
    buffer += "\n"
    buffer += fmt.Sprintf("> %s", post.Content)
    buffer += "\n"
    buffer += fmt.Sprintf("=> %s %s\n", baseURL("/createcomment/"+post.ID), "Add Comment")
    buffer += "\n"
    buffer += "## Comments\n"
    buffer += "\n"
    
    for _, comment := range handler.GetComments(a.DB, post.ID) {
        buffer += (comment.ID + "\n")
        buffer += (comment.Time + " UTC\n")
        buffer += fmt.Sprintf("> %s", comment.Content)
        buffer += "\n\n"
    }
    return c.Gemini(buffer)
}

// Create post handler
func (a *App) createPost(c gig.Context) error {
    // This will get the user input, if there is any
    q, err := c.QueryString()
    if err != nil {
        log.Fatal(err) // you should probably handle this differently in production, but this will do for now
    }
    
    if q == "" {
        return c.NoContent(gig.StatusInput, "Post Text")
    } else {
        id := handler.CreatePost(a.DB, q)
        return c.NoContent(gig.StatusRedirectTemporary, baseURL("/post/"+id))
    }
}

// Create comment handler
func (a *App) createComment(c gig.Context) error {
    q, err := c.QueryString()
	if err != nil {
		log.Fatal(err)
	}
	if q == "" {
		return c.NoContent(gig.StatusInput, "Add Comment")
	} else {
		post := handler.GetPost(a.DB, c.Param("id"))
		handler.AddComment(a.DB, q, post.ID)
		return c.NoContent(gig.StatusRedirectTemporary, baseURL("/post/"+post.ID))
	}
}

Wow that was a mess. As an excersise, you should try making a library for generating gemtext. I made a simple one for my capsules, but it’s not currently on GitHub. Sorry! Let’s finish up app/app.go.

func (a *App) Run(crt, key string) {
    log.Println("Server running at " + baseURL("/"))
    a.Router.Run(crt, key)
}

Before we write the last of our code, we need to generate a self-signed certificate. The easiest way to get a self-signed certificate is to use Solderpunk’s gemcert tool: https://tildegit.org/solderpunk/gemcert

You can also use openssl, but that’s beyond the scope of this tutorial. Once you have your crt and key, put them in the root of your project. Done? Cool, let’s write the last file, main.go.

package main

import (
    app "textboard/app"
)

func main() {
    app := &app.App{}
    app.Init()
    
    // Fill this in with the correct crt and key names, in quotes
    app.Run("<crt name>", "<key name>")
}

We’re done! So much code! Now, let’s build and run it.

go build
./textboard

You should now be able to connect to gemini://localhost/ in your Gemini client of choice! If it doesn’t compile, make sure you have all the requirements, and didn’t type anything wrong. If you get a TLS error in your client, make sure you used the correct domain name when generating your certificate. It would be localhost for, well, localhost. Or your capsule’s domain name if you have one.

Conclusion

This was a pretty messy project, and it’s not very useful, but it shows how interactive Gemini can really be. And maybe you learned a bit about Go along the way.

If you liked this post, feel free to check out my GitHub (it’s pretty boring): https://github.com/trebabcock

Thanks for reading!

Author: dt

Created: 2022-02-20 Sun 10:04