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/setname": book.serveSetName(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) serveSetName(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, "Name:") return } userId, err := UserIDFromCert(req.Certificate()) newName := req.URL.RawQuery slog.Info("Setting new name for guestbook user", "userId", userId, "newName", newName) if err != nil { slog.Error("Error creating user id from cert", "error", err) w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error generating user ID") return } slog.Info("Setting name", "newName", newName, "userId", userId) _, err = book.db.Exec("UPDATE guestbook SET name = ? WHERE user_id = ?", newName, userId) if err != nil { slog.Error("Error setting name for guestbook entry", "error", err) w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error setting name for guestbook entry") return } w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") var content strings.Builder content.WriteString("# Success!\n") content.WriteString("Updated the username for you entry / entries if you have mutiple using the same idenitiy\n\n") content.WriteString("\n\n=> /guestbook Back to guestbook") w.WriteBody([]byte(content.String())) } 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()) 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 result, 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 } _, err = result.LastInsertId() if err != nil { slog.Error("Error getting last insert ID", "error", err) w.WriteStatusMsg(gemini.StatusGeneralPermFail, "Error getting inserted ID") 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\n", name)) content.WriteString("This username was taken from your identity.\n=> /guestbook/setname Set custom name\n\n") content.WriteString(fmt.Sprintf("entry:\n> %s\n\n", 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 censorString(s string) string { masked := []rune(s) for i, c := range masked { if c != ' ' { masked[i] = 'x' } } return string(masked) } 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 { message = censorString(message) name = censorString(name) } 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())) }