diff --git a/Dockerfile b/Dockerfile index 0ea5a23..f19925e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/go.mod b/go.mod index 8850484..3984733 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index a1a1b63..b86cbc1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/guestbook/guestbook.go b/internal/guestbook/guestbook.go new file mode 100644 index 0000000..9054767 --- /dev/null +++ b/internal/guestbook/guestbook.go @@ -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())) +} diff --git a/internal/guestbook/handler.go b/internal/guestbook/handler.go new file mode 100644 index 0000000..eae9207 --- /dev/null +++ b/internal/guestbook/handler.go @@ -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) +} diff --git a/main.go b/main.go index 8aec1cb..315d397 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/pages/guestbook.gmi b/pages/guestbook.gmi new file mode 100644 index 0000000..9d08908 --- /dev/null +++ b/pages/guestbook.gmi @@ -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. + +------------------------ diff --git a/pages/home.gmi b/pages/home.gmi index 5a058ee..e7fc7e0 100644 --- a/pages/home.gmi +++ b/pages/home.gmi @@ -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: