diff --git a/Dockerfile b/Dockerfile index f19925e..0ea5a23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,8 +3,8 @@ FROM golang:1.25-alpine AS builder WORKDIR /app -# Install git, gcc, musl-dev, and sqlite-dev for go mod and CGO -RUN apk add --no-cache git gcc musl-dev sqlite-dev +# Install git if needed for go mod +RUN apk add --no-cache git # Copy go mod and sum files COPY go.mod go.sum ./ @@ -15,9 +15,6 @@ 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 3984733..8850484 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module gemini_site go 1.25.0 require ( - git.travisshears.com/travisshears/gemlog-cli v1.1.0 + git.travisshears.com/travisshears/gemlog-cli v1.0.1 github.com/kulak/gemini v1.2.2 - github.com/mattn/go-sqlite3 v1.14.32 ) diff --git a/go.sum b/go.sum index b86cbc1..a1a1b63 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,9 @@ -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= +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= 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 deleted file mode 100644 index 9054767..0000000 --- a/internal/guestbook/guestbook.go +++ /dev/null @@ -1,255 +0,0 @@ -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 deleted file mode 100644 index eae9207..0000000 --- a/internal/guestbook/handler.go +++ /dev/null @@ -1,17 +0,0 @@ -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 315d397..8aec1cb 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "database/sql" "errors" "flag" "fmt" @@ -11,10 +10,7 @@ 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" @@ -22,9 +18,8 @@ import ( ) type MainHandler struct { - blog microblog.Handler - gemlog gemlog.Handler - guestbook guestbook.Handler + blog microblog.Handler + gemlog gemlog.Handler } func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { @@ -33,17 +28,14 @@ func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { "user", strings.Join(userName(req), " ")) // Check if this is a blog request - switch { - case strings.HasPrefix(req.URL.Path, "/microblog"): + if strings.HasPrefix(req.URL.Path, "/microblog") { h.blog.HandleBlogRequest(w, req) return + } - case strings.HasPrefix(req.URL.Path, "/gemlog"): + if 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 { @@ -110,16 +102,6 @@ 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 @@ -130,12 +112,11 @@ func main() { pbClient := pocketbase.NewPocketBaseClient() handler := MainHandler{ - blog: microblog.NewHandler(pbClient), - gemlog: gemlog.NewHandler(), - guestbook: guestbook.NewGuestBook(db), + blog: microblog.NewHandler(pbClient), + gemlog: gemlog.NewHandler(), } - 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 deleted file mode 100644 index 9d08908..0000000 --- a/pages/guestbook.gmi +++ /dev/null @@ -1,13 +0,0 @@ -# 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 e7fc7e0..5a058ee 100644 --- a/pages/home.gmi +++ b/pages/home.gmi @@ -11,11 +11,7 @@ Hello, World! -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 +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. My personal website: => https://travisshears.com @@ -25,24 +21,17 @@ 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 -== 2025.10.07 == +== 06.10.2025 == -Added Guestbook! +Added gemlog! -=> /guestbook Sign it now +=> /gemlog -== 2025.10.06 == - -Added Gemlog! - -=> /gemlog Check it out - -== 2025.09.29 == +== 29.09.2025 == 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. @@ -50,7 +39,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 -== 2025.09.26 == +== 26.09.2025 == 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: