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!