Compare commits

...

3 commits

8 changed files with 340 additions and 19 deletions

View file

@ -3,8 +3,8 @@ FROM golang:1.25-alpine AS builder
WORKDIR /app
# Install git if needed for go mod
RUN apk add --no-cache git
# Install git, gcc, musl-dev, and sqlite-dev for go mod and CGO
RUN apk add --no-cache git gcc musl-dev sqlite-dev
# Copy go mod and sum files
COPY go.mod go.sum ./
@ -15,6 +15,9 @@ RUN go mod download
COPY main.go .
COPY internal ./internal
# Enable CGO for go-sqlite3
ENV CGO_ENABLED=1
RUN go build -o main .
FROM alpine:latest

3
go.mod
View file

@ -3,6 +3,7 @@ module gemini_site
go 1.25.0
require (
git.travisshears.com/travisshears/gemlog-cli v1.0.1
git.travisshears.com/travisshears/gemlog-cli v1.1.0
github.com/kulak/gemini v1.2.2
github.com/mattn/go-sqlite3 v1.14.32
)

6
go.sum
View file

@ -1,9 +1,11 @@
git.travisshears.com/travisshears/gemlog-cli v1.0.1 h1:0AcFwrnukwcWHuePEROG3WDSGbcE8WoFvJTQxrTaszc=
git.travisshears.com/travisshears/gemlog-cli v1.0.1/go.mod h1:N6l94N174EhDOIHU0/RlJ0PWrxB0BMa0W6LcpgAtvCE=
git.travisshears.com/travisshears/gemlog-cli v1.1.0 h1:iFMIeYyzPvoUw2sQqGg8PTejCcKxgmhjy5HqpVo3Ag8=
git.travisshears.com/travisshears/gemlog-cli v1.1.0/go.mod h1:N6l94N174EhDOIHU0/RlJ0PWrxB0BMa0W6LcpgAtvCE=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kulak/gemini v1.2.2 h1:wPFOAFFdOf9ZaHcpMwTq1xYUWxmyV3h0uQl0OXCGa+A=
github.com/kulak/gemini v1.2.2/go.mod h1:8yiD7yhLkUGvOpdvgd/0nKQD2I0ChIAKD3yHuT13R5k=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View file

@ -0,0 +1,255 @@
package guestbook
import (
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/hex"
"fmt"
"log/slog"
"os"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
gemini "github.com/kulak/gemini"
)
const adminUserId = "6a1171c755cbde90373bee2ac08c6aa4607ecf8ed3f50c55cf76c6a0e27dfa5c"
type GuestBook struct {
db *sql.DB
}
func NewGuestBook(db *sql.DB) *GuestBook {
err := initDB(db)
if err != nil {
slog.Error("Error migrating sqlite database", "error", err)
panic(err)
}
return &GuestBook{
db,
}
}
func initDB(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS guestbook (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
user_id INTEGER NOT NULL,
approved BOOLEAN NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`)
return err
}
func (book *GuestBook) HandleRequest(w gemini.ResponseWriter, req *gemini.Request) {
path := req.URL.Path
switch {
case path == "/guestbook" || path == "/guestbook/":
book.serveIndex(w, req)
case path == "/guestbook/new":
book.serveNewEntry(w, req)
case path == "/guestbook/admin":
book.serveAdmin(w, req)
case strings.HasPrefix(path, "/guestbook/approve"):
book.serveApprove(w, req)
case strings.HasPrefix(path, "/guestbook/delete"):
book.serveDelete(w, req)
default:
w.WriteStatusMsg(gemini.StatusNotFound, "Page not found")
}
}
func UserIDFromCert(cert *x509.Certificate) (string, error) {
pubKeyBytes, err := x509.MarshalPKIXPublicKey(cert.PublicKey)
if err != nil {
return "", err
}
hash := sha256.Sum256(pubKeyBytes)
return hex.EncodeToString(hash[:]), nil
}
func (book *GuestBook) serveDelete(w gemini.ResponseWriter, req *gemini.Request) {
if req.Certificate() == nil {
w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication Required")
return
}
userId, err := UserIDFromCert(req.Certificate())
if err != nil {
slog.Error("Error creating user id from cert", "error", err)
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error generating user ID")
return
}
if userId != adminUserId {
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Permission denied")
return
}
entryId := req.URL.Query().Get("id")
slog.Info("Deleting", "userId", userId, "entryId", entryId)
_, err = book.db.Exec("DELETE FROM guestbook WHERE id = ?", entryId)
if err != nil {
slog.Error("Error deleting guestbook entry", "error", err)
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error deleting guestbook entry")
return
}
book.serveAdmin(w, req)
}
func (book *GuestBook) serveApprove(w gemini.ResponseWriter, req *gemini.Request) {
if req.Certificate() == nil {
w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication Required")
return
}
userId, err := UserIDFromCert(req.Certificate())
if err != nil {
slog.Error("Error creating user id from cert", "error", err)
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error generating user ID")
return
}
if userId != adminUserId {
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Permission denied")
return
}
entryId := req.URL.Query().Get("id")
slog.Info("Approving", "userId", userId, "entryId", entryId)
_, err = book.db.Exec("UPDATE guestbook SET approved = true WHERE id = ?", entryId)
if err != nil {
slog.Error("Error approving guestbook entry", "error", err)
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error approving guestbook entry")
return
}
book.serveAdmin(w, req)
}
func (book *GuestBook) serveNewEntry(w gemini.ResponseWriter, req *gemini.Request) {
if req.Certificate() == nil {
w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication Required")
return
}
if req.URL.RawQuery == "" {
w.WriteStatusMsg(gemini.StatusPlainInput, "Your guestbook entry:")
return
}
// w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini")
name := req.Certificate().Subject.CommonName
userId, err := UserIDFromCert(req.Certificate())
slog.Info("User ID created", "user_id", userId)
if err != nil {
slog.Error("Error creating user id from cert", "error", err)
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error generating user ID")
return
}
txt := req.URL.RawQuery
_, err = book.db.Exec("INSERT INTO guestbook(name, user_id, approved, message) VALUES(?, ?, ?, ?)", name, userId, false, txt)
if err != nil {
slog.Error("Error writing to database", "error", err)
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error writing to database")
return
}
w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini")
var content strings.Builder
content.WriteString("# Success!\n")
content.WriteString("Your entry was added to the guestbook\n\n")
content.WriteString(fmt.Sprintf("username: %s\nentry:\n> %s\n\n", name, txt))
content.WriteString("For now since the entry has not been approved it will appear in the list with xxxxx in place of chars.\n")
content.WriteString("\n\n=> /guestbook Back to guestbook")
w.WriteBody([]byte(content.String()))
}
func (book *GuestBook) serveIndex(w gemini.ResponseWriter, req *gemini.Request) {
w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini")
var content strings.Builder
page, err := os.ReadFile("./pages/guestbook.gmi")
if err != nil {
slog.Error("Problem reading guestbook.gmi", "error", err)
return
}
content.Write(page)
content.WriteString("\n")
rows, err := book.db.Query("SELECT id, name, approved, created_at, message FROM guestbook ORDER BY created_at DESC")
if err != nil {
slog.Error("Error querying database", "error", err)
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error writing to database")
return
}
defer rows.Close()
for rows.Next() {
var id int
var name string
var approved bool
var createdAt time.Time
var message string
rows.Scan(&id, &name, &approved, &createdAt, &message)
if !approved {
// Overwrite all non-space characters in message with "x"
masked := []rune(message)
for i, c := range masked {
if c != ' ' {
masked[i] = 'x'
}
}
message = string(masked)
}
content.WriteString(fmt.Sprintf("%s :: %s\n> %s\n", createdAt.Format("2006-01-02"), name, message))
content.WriteString("\n")
}
defer rows.Close()
content.WriteString("=> / Back to home\n")
w.WriteBody([]byte(content.String()))
}
func (book *GuestBook) serveAdmin(w gemini.ResponseWriter, req *gemini.Request) {
if req.Certificate() == nil {
w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication Required")
return
}
w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini")
userId, err := UserIDFromCert(req.Certificate())
if err != nil {
slog.Error("Error creating user id from cert", "error", err)
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error generating user ID")
return
}
if userId != adminUserId {
w.WriteStatusMsg(gemini.StatusBadRequest, "UserId != adminUserId")
return
}
var content strings.Builder
rows, err := book.db.Query("SELECT id, user_id, name, approved, created_at, message FROM guestbook ORDER BY created_at DESC")
if err != nil {
slog.Error("Error querying database", "error", err)
w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error writing to database")
return
}
defer rows.Close()
for rows.Next() {
var id int
var userId string
var name string
var approved bool
var createdAt time.Time
var message string
rows.Scan(&id, &userId, &name, &approved, &createdAt, &message)
content.WriteString(fmt.Sprintf("%s :: %s\n> %s\n", createdAt.Format("2006-01-02"), name, message))
if !approved {
content.WriteString(fmt.Sprintf("=> /guestbook/approve?id=%d Approve\n", id))
}
content.WriteString(fmt.Sprintf("=> /guestbook/delete?id=%d Delete\n", id))
content.WriteString("\n")
}
defer rows.Close()
content.WriteString("=> /guestbook Back to guesbook\n")
content.WriteString("=> / Back to home\n")
w.WriteBody([]byte(content.String()))
}

View file

@ -0,0 +1,17 @@
package guestbook
import (
"database/sql"
gemini "github.com/kulak/gemini"
)
// Handler interface for microblog functionality
type Handler interface {
HandleRequest(w gemini.ResponseWriter, req *gemini.Request)
}
// NewHandler creates a new microblog handler
func NewHandler(db *sql.DB) Handler {
return NewGuestBook(db)
}

35
main.go
View file

@ -1,6 +1,7 @@
package main
import (
"database/sql"
"errors"
"flag"
"fmt"
@ -10,7 +11,10 @@ import (
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
gemlog "gemini_site/internal/gemlog"
"gemini_site/internal/guestbook"
microblog "gemini_site/internal/microblog"
"gemini_site/internal/pocketbase"
@ -18,8 +22,9 @@ import (
)
type MainHandler struct {
blog microblog.Handler
gemlog gemlog.Handler
blog microblog.Handler
gemlog gemlog.Handler
guestbook guestbook.Handler
}
func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) {
@ -28,14 +33,17 @@ func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) {
"user", strings.Join(userName(req), " "))
// Check if this is a blog request
if strings.HasPrefix(req.URL.Path, "/microblog") {
switch {
case strings.HasPrefix(req.URL.Path, "/microblog"):
h.blog.HandleBlogRequest(w, req)
return
}
if strings.HasPrefix(req.URL.Path, "/gemlog") {
case strings.HasPrefix(req.URL.Path, "/gemlog"):
h.gemlog.HandleRequest(w, req)
return
case strings.HasPrefix(req.URL.Path, "/guestbook"):
h.guestbook.HandleRequest(w, req)
return
}
switch req.URL.Path {
@ -102,6 +110,16 @@ func main() {
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
// Setup DB
sqlitePath := os.Getenv("SQLITE_PATH")
if sqlitePath == "" {
panic("SQLITE_PATH environment variable must be set and non-empty")
}
db, err := sql.Open("sqlite3", sqlitePath)
if err != nil {
panic(err)
}
defer db.Close()
slog.Info("Starting gemini server")
var host, cert, key string
@ -112,11 +130,12 @@ func main() {
pbClient := pocketbase.NewPocketBaseClient()
handler := MainHandler{
blog: microblog.NewHandler(pbClient),
gemlog: gemlog.NewHandler(),
blog: microblog.NewHandler(pbClient),
gemlog: gemlog.NewHandler(),
guestbook: guestbook.NewGuestBook(db),
}
err := gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini))
err = gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini))
if err != nil {
slog.Error("server failed to start", "error", err)
os.Exit(1)

13
pages/guestbook.gmi Normal file
View file

@ -0,0 +1,13 @@
# Guestbook
A place to leave messages.
Hey gemspace! Thanks for visiting my capsule. Why not leave a message?
=> /guestbook/new Sign the guestbook
=> /guestbook/admin Admin
Messages like "xxxxx xxxx" mean they are awaiting my approval. Once approved, the actual text will be rendered.
------------------------

View file

@ -11,7 +11,11 @@
Hello, World!
Welcome to my Gemini capsule. This capsule is in very early stages of development. Mostly a proof of concept at this point. I plan to add more features like a gemlog soon. Looking forward to writing and interacting with folks in gemspace.
Welcome to my Gemini capsule. Est 2025.09.
This capsule is my playground for experimenting with text based content and interacting with folks in gemspace.
Want to get in touch? Shoot me an email at t [at] travisshears.com
My personal website:
=> https://travisshears.com
@ -21,17 +25,24 @@ So far I've joined the following communities here in gemspace:
## Capsule Features
=> /gemlog Gemlog - Gemini exclusive blog
=> /guestbook Guestbook - Open guestbook for visitors to sign
=> /microblog Microblog - Aggregation of all my microblog posts
## Site updates
== 06.10.2025 ==
== 2025.10.07 ==
Added gemlog!
Added Guestbook!
=> /gemlog
=> /guestbook Sign it now
== 29.09.2025 ==
== 2025.10.06 ==
Added Gemlog!
=> /gemlog Check it out
== 2025.09.29 ==
Added microblog feature rendering my nostr post feed. Nice to have a dynamic source of content in this capsule to keep it fresh between updates.
@ -39,7 +50,7 @@ Ideas for next features:
* Guestbook backed by sqlite. Would be fun to playout sqlite and golang. Could also be intresting to do a telegram notification.
* Code block rendering from my git hosting
== 26.09.2025 ==
== 2025.09.26 ==
Initial deployment
Took quite some tinkering to get everything working, but it's finally up and running! If you are reading this then I got the following architecture working: