From e1726090394cac2f3a1692c6cb0e417776c74ab8 Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Fri, 26 Sep 2025 13:40:31 +0200 Subject: [PATCH 01/11] copy pages dir to docker image during build --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 0910dbb..fd8de56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ WORKDIR /app COPY --from=builder /app/main . COPY keys ./keys +COPY pages ./pages EXPOSE 8080 From 1c7341499a0fc32da3590498dcf5037d8e15723c Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Fri, 26 Sep 2025 21:12:29 +0200 Subject: [PATCH 02/11] switch to json slog --- dev.sh | 2 +- main.go | 18 ++++++++++++++---- pages/home.gmi | 2 ++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/dev.sh b/dev.sh index 947d997..23a241e 100755 --- a/dev.sh +++ b/dev.sh @@ -2,4 +2,4 @@ set -e -go run main.go -cert=./keys/cert.crt.pem -key=./keys/localhost_key.pem -host=localhost:8080 +go run main.go -cert=./keys/localhost.crt.pem -key=./keys/localhost_key.pem -host=localhost:8080 diff --git a/main.go b/main.go index e139a6b..69ece37 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,8 @@ import ( "errors" "flag" "fmt" - "log" + "log/slog" + "os" "strconv" "strings" "time" @@ -16,7 +17,9 @@ type ExampleHandler struct { } func (h ExampleHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { - log.Printf("request: %s, user: %v", req.URL.Path, strings.Join(userName(req), " ")) + slog.Info("gemini request", + "path", req.URL.Path, + "user", strings.Join(userName(req), " ")) switch req.URL.Path { case "/": gemini.ServeFileName("pages/home.gmi", "text/gemini")(w, req) @@ -76,7 +79,13 @@ func userName(r *gemini.Request) []string { } func main() { - println("Starting gemini server") + // Set up structured JSON logging + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(logger) + + slog.Info("Starting gemini server") var host, cert, key string flag.StringVar(&host, "host", ":1965", "listen on host and port. Example: hostname:1965") flag.StringVar(&cert, "cert", "server.crt.pem", "certificate file") @@ -87,6 +96,7 @@ func main() { err := gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini)) if err != nil { - log.Fatal(err) + slog.Error("server failed to start", "error", err) + os.Exit(1) } } diff --git a/pages/home.gmi b/pages/home.gmi index 6a1a03e..7ac5864 100644 --- a/pages/home.gmi +++ b/pages/home.gmi @@ -48,3 +48,5 @@ Took quite some tinkering to get everything working, but it's finally up and run You can find the source code for this capsule here: => https://git.travisshears.com/travisshears/personal-gemini-capsule +Diagram created with: +=> https://asciiflow.com From 216ace7afa2ea8588c21fd85c102b89af799eb72 Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Fri, 26 Sep 2025 22:25:59 +0200 Subject: [PATCH 03/11] init llm generated microblog submodule --- .gitignore | 1 + internal/microblog/handler.go | 15 +++ internal/microblog/microblog.go | 208 ++++++++++++++++++++++++++++++++ main.go | 14 ++- pages/home.gmi | 4 + 5 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 internal/microblog/handler.go create mode 100644 internal/microblog/microblog.go diff --git a/.gitignore b/.gitignore index 81bbbae..a82f26a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ keys/ +gemini-server diff --git a/internal/microblog/handler.go b/internal/microblog/handler.go new file mode 100644 index 0000000..07ceee0 --- /dev/null +++ b/internal/microblog/handler.go @@ -0,0 +1,15 @@ +package microblog + +import ( + gemini "github.com/kulak/gemini" +) + +// Handler interface for microblog functionality +type Handler interface { + HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Request) +} + +// NewHandler creates a new microblog handler +func NewHandler() Handler { + return NewMicroBlog() +} diff --git a/internal/microblog/microblog.go b/internal/microblog/microblog.go new file mode 100644 index 0000000..b74c15a --- /dev/null +++ b/internal/microblog/microblog.go @@ -0,0 +1,208 @@ +package microblog + +import ( + "fmt" + "sort" + "strings" + "time" + + gemini "github.com/kulak/gemini" +) + +// Post represents a single blog post +type Post struct { + ID string + Title string + Content string + Author string + Timestamp time.Time +} + +// MicroBlog manages blog posts +type MicroBlog struct { + posts []Post +} + +// NewMicroBlog creates a new microblog instance +func NewMicroBlog() *MicroBlog { + mb := &MicroBlog{ + posts: make([]Post, 0), + } + + // Add some sample posts + mb.addSamplePosts() + + return mb +} + +// addSamplePosts adds some initial content +func (mb *MicroBlog) addSamplePosts() { + samplePosts := []Post{ + { + ID: "1", + Title: "Welcome to the Gemini Microblog", + Content: "This is the first post on our Gemini-powered microblog! It's simple, fast, and distraction-free.", + Author: "Admin", + Timestamp: time.Now().Add(-2 * time.Hour), + }, + { + ID: "2", + Title: "The Beauty of Simplicity", + Content: "Gemini protocol encourages us to focus on content over presentation. This microblog embodies that philosophy.", + Author: "Admin", + Timestamp: time.Now().Add(-1 * time.Hour), + }, + } + + mb.posts = append(mb.posts, samplePosts...) +} + +// AddPost adds a new post to the blog +func (mb *MicroBlog) AddPost(title, content, author string) string { + id := fmt.Sprintf("%d", time.Now().Unix()) + post := Post{ + ID: id, + Title: title, + Content: content, + Author: author, + Timestamp: time.Now(), + } + + mb.posts = append(mb.posts, post) + return id +} + +// GetPost retrieves a post by ID +func (mb *MicroBlog) GetPost(id string) (*Post, bool) { + for _, post := range mb.posts { + if post.ID == id { + return &post, true + } + } + return nil, false +} + +// GetRecentPosts returns the most recent posts +func (mb *MicroBlog) GetRecentPosts(limit int) []Post { + // Sort posts by timestamp (newest first) + sortedPosts := make([]Post, len(mb.posts)) + copy(sortedPosts, mb.posts) + + sort.Slice(sortedPosts, func(i, j int) bool { + return sortedPosts[i].Timestamp.After(sortedPosts[j].Timestamp) + }) + + if limit > 0 && len(sortedPosts) > limit { + return sortedPosts[:limit] + } + + return sortedPosts +} + +// HandleBlogRequest handles Gemini requests for the microblog +func (mb *MicroBlog) HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Request) { + path := req.URL.Path + + switch { + case path == "/blog" || path == "/blog/": + mb.serveBlogIndex(w, req) + case strings.HasPrefix(path, "/blog/post/"): + postID := strings.TrimPrefix(path, "/blog/post/") + mb.servePost(w, req, postID) + case path == "/blog/new": + mb.serveNewPostForm(w, req) + default: + w.WriteStatusMsg(gemini.StatusNotFound, "Blog page not found") + } +} + +// serveBlogIndex serves the main blog page with recent posts +func (mb *MicroBlog) serveBlogIndex(w gemini.ResponseWriter, req *gemini.Request) { + w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + + var content strings.Builder + content.WriteString("# Gemini Microblog\n\n") + + posts := mb.GetRecentPosts(10) + + if len(posts) == 0 { + content.WriteString("No posts yet. Be the first to write something!\n\n") + } else { + content.WriteString("## Recent Posts\n\n") + + for _, post := range posts { + content.WriteString(fmt.Sprintf("=> /blog/post/%s %s\n", post.ID, post.Title)) + content.WriteString(fmt.Sprintf(" By %s on %s\n\n", + post.Author, + post.Timestamp.Format("2006-01-02 15:04"))) + } + } + + content.WriteString("## Actions\n\n") + content.WriteString("=> /blog/new Write a new post\n") + content.WriteString("=> / Back to home\n") + + w.WriteBody([]byte(content.String())) +} + +// servePost serves a single blog post +func (mb *MicroBlog) servePost(w gemini.ResponseWriter, req *gemini.Request, postID string) { + post, found := mb.GetPost(postID) + if !found { + w.WriteStatusMsg(gemini.StatusNotFound, "Post not found") + return + } + + w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + + var content strings.Builder + content.WriteString(fmt.Sprintf("# %s\n\n", post.Title)) + content.WriteString(fmt.Sprintf("By %s on %s\n\n", + post.Author, + post.Timestamp.Format("2006-01-02 15:04"))) + content.WriteString("---\n\n") + content.WriteString(post.Content) + content.WriteString("\n\n---\n\n") + content.WriteString("=> /blog Back to blog\n") + content.WriteString("=> / Back to home\n") + + w.WriteBody([]byte(content.String())) +} + +// serveNewPostForm serves the form for creating a new post +func (mb *MicroBlog) serveNewPostForm(w gemini.ResponseWriter, req *gemini.Request) { + // Check if user is authenticated + if req.Certificate() == nil { + w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication required to create posts") + return + } + + w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + + var content strings.Builder + content.WriteString("# Write a New Post\n\n") + content.WriteString("To create a new post, use the Titan protocol:\n\n") + content.WriteString("titan://your-server/blog/create;mime=text/plain;token=your-auth-token\n\n") + content.WriteString("Format your post as:\n") + content.WriteString("Title: Your Post Title\n") + content.WriteString("Content: Your post content goes here...\n\n") + content.WriteString("=> /blog Back to blog\n") + + w.WriteBody([]byte(content.String())) +} + +// parsePostData parses the post data from Titan payload +func (mb *MicroBlog) parsePostData(data string) (title, content string) { + lines := strings.Split(data, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Title:") { + title = strings.TrimSpace(strings.TrimPrefix(line, "Title:")) + } else if strings.HasPrefix(line, "Content:") { + content = strings.TrimSpace(strings.TrimPrefix(line, "Content:")) + } + } + + return title, content +} diff --git a/main.go b/main.go index 69ece37..bfd7582 100644 --- a/main.go +++ b/main.go @@ -10,16 +10,26 @@ import ( "strings" "time" + "gemini_site/internal/microblog" + gemini "github.com/kulak/gemini" ) type ExampleHandler struct { + blog microblog.Handler } func (h ExampleHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { slog.Info("gemini request", "path", req.URL.Path, "user", strings.Join(userName(req), " ")) + + // Check if this is a blog request + if strings.HasPrefix(req.URL.Path, "/blog") { + h.blog.HandleBlogRequest(w, req) + return + } + switch req.URL.Path { case "/": gemini.ServeFileName("pages/home.gmi", "text/gemini")(w, req) @@ -92,7 +102,9 @@ func main() { flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file") flag.Parse() - handler := ExampleHandler{} + handler := ExampleHandler{ + blog: microblog.NewHandler(), + } err := gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini)) if err != nil { diff --git a/pages/home.gmi b/pages/home.gmi index 7ac5864..d3909b0 100644 --- a/pages/home.gmi +++ b/pages/home.gmi @@ -18,6 +18,10 @@ My personal website: So far I've joined the following communities here in gemspace: => gemini://station.martinrue.com/travisshears +## Features + +=> /blog Microblog - Read and write posts + ## Site updates == 26.09.2025 == From 654df9871155ee67bfb04547109920bb573ed57c Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Sat, 27 Sep 2025 10:07:45 +0200 Subject: [PATCH 04/11] init pocketbase logic for microblog posts --- .env | 3 + .gitignore | 1 + go.mod | 4 +- internal/microblog/microblog.go | 16 -- internal/microblog/pocketbase.go | 227 +++++++++++++++++++++++ internal/microblog/pocketbase_example.go | 19 ++ test_pocketbase.go | 20 ++ 7 files changed, 273 insertions(+), 17 deletions(-) create mode 100644 .env create mode 100644 internal/microblog/pocketbase.go create mode 100644 internal/microblog/pocketbase_example.go create mode 100644 test_pocketbase.go diff --git a/.env b/.env new file mode 100644 index 0000000..e48c393 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +export POCKET_BASE_PW=ifk2HEbM +export POCKET_BASE_HOST=http://aemos:5000 +export POCKET_BASE_USER=micro_blog_fetchers diff --git a/.gitignore b/.gitignore index a82f26a..1ab3e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ keys/ gemini-server +gemini_site diff --git a/go.mod b/go.mod index 61f416f..a5895a6 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,6 @@ module gemini_site go 1.25.0 -require github.com/kulak/gemini v1.2.2 +require ( + github.com/kulak/gemini v1.2.2 +) diff --git a/internal/microblog/microblog.go b/internal/microblog/microblog.go index b74c15a..d9e69c8 100644 --- a/internal/microblog/microblog.go +++ b/internal/microblog/microblog.go @@ -190,19 +190,3 @@ func (mb *MicroBlog) serveNewPostForm(w gemini.ResponseWriter, req *gemini.Reque w.WriteBody([]byte(content.String())) } - -// parsePostData parses the post data from Titan payload -func (mb *MicroBlog) parsePostData(data string) (title, content string) { - lines := strings.Split(data, "\n") - - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "Title:") { - title = strings.TrimSpace(strings.TrimPrefix(line, "Title:")) - } else if strings.HasPrefix(line, "Content:") { - content = strings.TrimSpace(strings.TrimPrefix(line, "Content:")) - } - } - - return title, content -} diff --git a/internal/microblog/pocketbase.go b/internal/microblog/pocketbase.go new file mode 100644 index 0000000..0f7a16e --- /dev/null +++ b/internal/microblog/pocketbase.go @@ -0,0 +1,227 @@ +package microblog + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strconv" + "sync" + "time" +) + +// Source represents the different social media sources +type Source string + +const ( + SourcePleroma Source = "pleroma" + SourceBlueSky Source = "blue_sky" + SourceMastodon Source = "mastodon" + SourcePixelfed Source = "pixelfed" + SourceNostr Source = "nostr" +) + +// IsTech represents whether a post is tech-related +type IsTech string + +const ( + IsTechYesAI IsTech = "yes_ai" + IsTechNo IsTech = "no" + IsTechYesHuman IsTech = "yes_human" +) + +// PBPost represents a microblog post from PocketBase +type PBPost struct { + ID string `json:"id"` + RemoteID string `json:"remoteId"` + Source Source `json:"source"` + FullPost json.RawMessage `json:"fullPost"` + Posted string `json:"posted"` + IsTech IsTech `json:"isTech"` +} + +// PostsResponse represents the response from PocketBase API +type PostsResponse struct { + Items []PBPost `json:"items"` + Page int `json:"page"` + Total int `json:"totalItems"` +} + +// AuthResponse represents the authentication response from PocketBase +type AuthResponse struct { + Token string `json:"token"` + User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + } `json:"record"` +} + +// TokenCache holds the cached authentication token +type TokenCache struct { + Token string + FetchedAt time.Time + mu sync.RWMutex +} + +// PocketBaseClient handles communication with PocketBase API +type PocketBaseClient struct { + host string + username string + password string + tokenCache *TokenCache + httpClient *http.Client +} + +// NewPocketBaseClient creates a new PocketBase client +func NewPocketBaseClient() *PocketBaseClient { + return &PocketBaseClient{ + host: getEnvOrDefault("POCKET_BASE_HOST", "http://localhost:8090"), + username: getEnvOrDefault("POCKET_BASE_USER", ""), + password: getEnvOrDefault("POCKET_BASE_PW", ""), + tokenCache: &TokenCache{}, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// getEnvOrDefault gets environment variable or returns default value +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// isOlderThanADay checks if the given time is older than 23 hours +func isOlderThanADay(fetchedAt time.Time) bool { + if fetchedAt.IsZero() { + return true + } + duration := time.Since(fetchedAt) + return duration.Hours() > 23 +} + +// getNewLoginToken fetches a new authentication token from PocketBase +func (c *PocketBaseClient) getNewLoginToken() (string, error) { + loginURL := fmt.Sprintf("%s/api/collections/users/auth-with-password", c.host) + + loginData := map[string]string{ + "identity": c.username, + "password": c.password, + } + + jsonData, err := json.Marshal(loginData) + if err != nil { + return "", fmt.Errorf("failed to marshal login data: %w", err) + } + + resp, err := c.httpClient.Post(loginURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to make login request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp AuthResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return "", fmt.Errorf("failed to decode auth response: %w", err) + } + + return authResp.Token, nil +} + +// getLoginTokenWithCache gets a cached token or fetches a new one if needed +func (c *PocketBaseClient) getLoginTokenWithCache() (string, error) { + c.tokenCache.mu.RLock() + token := c.tokenCache.Token + fetchedAt := c.tokenCache.FetchedAt + c.tokenCache.mu.RUnlock() + + if token != "" && !isOlderThanADay(fetchedAt) { + return token, nil + } + + c.tokenCache.mu.Lock() + defer c.tokenCache.mu.Unlock() + + // Double-check after acquiring write lock + if c.tokenCache.Token != "" && !isOlderThanADay(c.tokenCache.FetchedAt) { + return c.tokenCache.Token, nil + } + + newToken, err := c.getNewLoginToken() + if err != nil { + return "", err + } + + c.tokenCache.Token = newToken + c.tokenCache.FetchedAt = time.Now() + + return newToken, nil +} + +// makeAuthenticatedRequest makes an HTTP request with authentication +func (c *PocketBaseClient) makeAuthenticatedRequest(method, url string, params url.Values) (*http.Response, error) { + token, err := c.getLoginTokenWithCache() + if err != nil { + return nil, fmt.Errorf("failed to get auth token: %w", err) + } + + if params != nil { + url += "?" + params.Encode() + } + + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/json") + + return c.httpClient.Do(req) +} + +// GetAllPostsBySource retrieves all posts for a given source with pagination +func (c *PocketBaseClient) GetPosts(page int) ([]PBPost, error) { + slog.Info("Getting microblog posts", "page", page) + pageSize := 15 + + params := url.Values{ + "page": {strconv.Itoa(page)}, + "perPage": {strconv.Itoa(pageSize)}, + "sort": {"-posted"}, + "skipTotal": {"true"}, + // TODO: add additional fields like image and tag? + } + + apiURL := fmt.Sprintf("%s/api/collections/micro_blog_posts/records", c.host) + resp, err := c.makeAuthenticatedRequest("GET", apiURL, params) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var postsResp PostsResponse + if err := json.NewDecoder(resp.Body).Decode(&postsResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + slog.Info("Pocketbase Res", "res", postsResp) + + return postsResp.Items, nil +} diff --git a/internal/microblog/pocketbase_example.go b/internal/microblog/pocketbase_example.go new file mode 100644 index 0000000..abccb34 --- /dev/null +++ b/internal/microblog/pocketbase_example.go @@ -0,0 +1,19 @@ +package microblog + +import ( + "fmt" + "log" +) + +func main() { + client := NewPocketBaseClient() + fmt.Println("Getting page 1 of microblog posts") + posts, err := client.GetPosts(1) + if err != nil { + log.Printf("Error getting posts: %v", err) + } else { + fmt.Printf("Got first page of microblog posts\n") + fmt.Printf("First post: %v\n", posts[0]) + } + +} diff --git a/test_pocketbase.go b/test_pocketbase.go new file mode 100644 index 0000000..8d5c64f --- /dev/null +++ b/test_pocketbase.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "log" + + "gemini_site/internal/microblog" +) + +func main() { + client := microblog.NewPocketBaseClient() + fmt.Println("Getting page 1 of microblog posts") + posts, err := client.GetPosts(1) + if err != nil { + log.Printf("Error getting posts: %v", err) + } else { + fmt.Printf("Got first page of microblog posts\n") + fmt.Printf("First post: %v\n", posts[0]) + } +} From 2837293474be9329ebe5018296427bcd6550b0be Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Sat, 27 Sep 2025 14:39:18 +0200 Subject: [PATCH 05/11] create seprate pocketbase client --- internal/microblog/microblog.go | 68 ++++++----- internal/microblog/pocketbase.go | 2 +- internal/pocketbase/pb.go | 196 +++++++++++++++++++++++++++++++ main.go | 8 +- pages/home.gmi | 4 +- test_pocketbase.go | 10 +- 6 files changed, 245 insertions(+), 43 deletions(-) create mode 100644 internal/pocketbase/pb.go diff --git a/internal/microblog/microblog.go b/internal/microblog/microblog.go index d9e69c8..b511535 100644 --- a/internal/microblog/microblog.go +++ b/internal/microblog/microblog.go @@ -1,6 +1,7 @@ package microblog import ( + "encoding/json" "fmt" "sort" "strings" @@ -9,15 +10,42 @@ import ( gemini "github.com/kulak/gemini" ) +type source string + +const ( + sourcePleroma source = "pleroma" + sourceBlueSky source = "blue_sky" + sourceMastodon source = "mastodon" + sourcePixelfed source = "pixelfed" + sourceNostr source = "nostr" +) + +var supportedSources = []source{ + sourceNostr, + // TODO: Add support for BlueSky and Mastodon + // SourceBlueSky, + // SourceMastodon, +} + // Post represents a single blog post -type Post struct { - ID string - Title string - Content string - Author string +type post struct { + ID string + RemoteID string + Content string + // TODO: add support for images, must extend the pocketbase query + // Images []string Timestamp time.Time } +// PBPost represents a microblog post from PocketBase +type pbPost struct { + ID string `json:"id"` + RemoteID string `json:"remoteId"` + Source Source `json:"source"` + FullPost json.RawMessage `json:"fullPost"` + Posted string `json:"posted"` +} + // MicroBlog manages blog posts type MicroBlog struct { posts []Post @@ -104,13 +132,11 @@ func (mb *MicroBlog) HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Requ path := req.URL.Path switch { - case path == "/blog" || path == "/blog/": + case path == "/microblog" || path == "/microblog/": mb.serveBlogIndex(w, req) - case strings.HasPrefix(path, "/blog/post/"): - postID := strings.TrimPrefix(path, "/blog/post/") + case strings.HasPrefix(path, "/microblog/post/"): + postID := strings.TrimPrefix(path, "/microblog/post/") mb.servePost(w, req, postID) - case path == "/blog/new": - mb.serveNewPostForm(w, req) default: w.WriteStatusMsg(gemini.StatusNotFound, "Blog page not found") } @@ -168,25 +194,3 @@ func (mb *MicroBlog) servePost(w gemini.ResponseWriter, req *gemini.Request, pos w.WriteBody([]byte(content.String())) } - -// serveNewPostForm serves the form for creating a new post -func (mb *MicroBlog) serveNewPostForm(w gemini.ResponseWriter, req *gemini.Request) { - // Check if user is authenticated - if req.Certificate() == nil { - w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication required to create posts") - return - } - - w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - - var content strings.Builder - content.WriteString("# Write a New Post\n\n") - content.WriteString("To create a new post, use the Titan protocol:\n\n") - content.WriteString("titan://your-server/blog/create;mime=text/plain;token=your-auth-token\n\n") - content.WriteString("Format your post as:\n") - content.WriteString("Title: Your Post Title\n") - content.WriteString("Content: Your post content goes here...\n\n") - content.WriteString("=> /blog Back to blog\n") - - w.WriteBody([]byte(content.String())) -} diff --git a/internal/microblog/pocketbase.go b/internal/microblog/pocketbase.go index 0f7a16e..15ace81 100644 --- a/internal/microblog/pocketbase.go +++ b/internal/microblog/pocketbase.go @@ -221,7 +221,7 @@ func (c *PocketBaseClient) GetPosts(page int) ([]PBPost, error) { if err := json.NewDecoder(resp.Body).Decode(&postsResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } - slog.Info("Pocketbase Res", "res", postsResp) + // slog.Info("Pocketbase Res", "res", postsResp) return postsResp.Items, nil } diff --git a/internal/pocketbase/pb.go b/internal/pocketbase/pb.go new file mode 100644 index 0000000..c0e1685 --- /dev/null +++ b/internal/pocketbase/pb.go @@ -0,0 +1,196 @@ +package pocketbase + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "strconv" + "sync" + "time" +) + +type pbRes struct { + Items json.RawMessage `json:"items"` + Page int `json:"page"` + Total int `json:"totalItems"` +} + +// AuthResponse represents the authentication response from PocketBase +type authResponse struct { + Token string `json:"token"` + User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + } `json:"record"` +} + +// TokenCache holds the cached authentication token +type tokenCache struct { + Token string + FetchedAt time.Time + mu sync.RWMutex +} + +// PocketBaseClient handles communication with PocketBase API +type PocketBaseClient struct { + host string + username string + password string + tokenCache *tokenCache + httpClient *http.Client +} + +// NewPocketBaseClient creates a new PocketBase client +func NewPocketBaseClient() *PocketBaseClient { + return &PocketBaseClient{ + host: getEnvOrDefault("POCKET_BASE_HOST", "http://localhost:8090"), + username: getEnvOrDefault("POCKET_BASE_USER", ""), + password: getEnvOrDefault("POCKET_BASE_PW", ""), + tokenCache: &tokenCache{}, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// getEnvOrDefault gets environment variable or returns default value +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// isOlderThanADay checks if the given time is older than 23 hours +func isOlderThanADay(fetchedAt time.Time) bool { + if fetchedAt.IsZero() { + return true + } + duration := time.Since(fetchedAt) + return duration.Hours() > 23 +} + +// getNewLoginToken fetches a new authentication token from PocketBase +func (c *PocketBaseClient) getNewLoginToken() (string, error) { + loginURL := fmt.Sprintf("%s/api/collections/users/auth-with-password", c.host) + + loginData := map[string]string{ + "identity": c.username, + "password": c.password, + } + + jsonData, err := json.Marshal(loginData) + if err != nil { + return "", fmt.Errorf("failed to marshal login data: %w", err) + } + + resp, err := c.httpClient.Post(loginURL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to make login request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body)) + } + + var authResp authResponse + if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil { + return "", fmt.Errorf("failed to decode auth response: %w", err) + } + + return authResp.Token, nil +} + +// getLoginTokenWithCache gets a cached token or fetches a new one if needed +func (c *PocketBaseClient) getLoginTokenWithCache() (string, error) { + c.tokenCache.mu.RLock() + token := c.tokenCache.Token + fetchedAt := c.tokenCache.FetchedAt + c.tokenCache.mu.RUnlock() + + if token != "" && !isOlderThanADay(fetchedAt) { + return token, nil + } + + c.tokenCache.mu.Lock() + defer c.tokenCache.mu.Unlock() + + // Double-check after acquiring write lock + if c.tokenCache.Token != "" && !isOlderThanADay(c.tokenCache.FetchedAt) { + return c.tokenCache.Token, nil + } + + newToken, err := c.getNewLoginToken() + if err != nil { + return "", err + } + + c.tokenCache.Token = newToken + c.tokenCache.FetchedAt = time.Now() + + return newToken, nil +} + +// makeAuthenticatedRequest makes an HTTP request with authentication +func (c *PocketBaseClient) makeAuthenticatedRequest(method, url string, params url.Values) (*http.Response, error) { + token, err := c.getLoginTokenWithCache() + if err != nil { + return nil, fmt.Errorf("failed to get auth token: %w", err) + } + + if params != nil { + url += "?" + params.Encode() + } + + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", token) + req.Header.Set("Content-Type", "application/json") + + return c.httpClient.Do(req) +} + +func (c *PocketBaseClient) GetList( + collection string, + page int, + pageSize int, + sort string) (*pbRes, error) { + slog.Info( + "Getting list of items from pocketbase", + "page", page, + "pageSize", pageSize, + "collection", collection) + params := url.Values{ + "page": {strconv.Itoa(page)}, + "perPage": {strconv.Itoa(pageSize)}, + "sort": {sort}, + "skipTotal": {"true"}, + // TODO: add additional fields like image and tag? + } + apiURL := fmt.Sprintf("%s/api/collections/%s/records", c.host, collection) + resp, err := c.makeAuthenticatedRequest("GET", apiURL, params) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var res pbRes + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &res, nil +} diff --git a/main.go b/main.go index bfd7582..f73ddea 100644 --- a/main.go +++ b/main.go @@ -15,17 +15,17 @@ import ( gemini "github.com/kulak/gemini" ) -type ExampleHandler struct { +type MainHandler struct { blog microblog.Handler } -func (h ExampleHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { +func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { slog.Info("gemini request", "path", req.URL.Path, "user", strings.Join(userName(req), " ")) // Check if this is a blog request - if strings.HasPrefix(req.URL.Path, "/blog") { + if strings.HasPrefix(req.URL.Path, "/microblog") { h.blog.HandleBlogRequest(w, req) return } @@ -102,7 +102,7 @@ func main() { flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file") flag.Parse() - handler := ExampleHandler{ + handler := MainHandler{ blog: microblog.NewHandler(), } diff --git a/pages/home.gmi b/pages/home.gmi index d3909b0..f7d0eb2 100644 --- a/pages/home.gmi +++ b/pages/home.gmi @@ -18,9 +18,9 @@ My personal website: So far I've joined the following communities here in gemspace: => gemini://station.martinrue.com/travisshears -## Features +## Capsule Features -=> /blog Microblog - Read and write posts +=> /microblog Microblog - Aggregation of all my microblog posts from Mastodon, BlueSky, and Nostr. ## Site updates diff --git a/test_pocketbase.go b/test_pocketbase.go index 8d5c64f..c4aed1a 100644 --- a/test_pocketbase.go +++ b/test_pocketbase.go @@ -4,17 +4,19 @@ import ( "fmt" "log" - "gemini_site/internal/microblog" + "gemini_site/internal/pocketbase" ) func main() { - client := microblog.NewPocketBaseClient() + pbClient := pocketbase.NewPocketBaseClient() + res, err := pbClient.GetList("micro_blog_posts", 1, 10, "-posted") fmt.Println("Getting page 1 of microblog posts") - posts, err := client.GetPosts(1) + // posts, err := client.GetPosts(1) + if err != nil { log.Printf("Error getting posts: %v", err) } else { fmt.Printf("Got first page of microblog posts\n") - fmt.Printf("First post: %v\n", posts[0]) + fmt.Printf("First post: %v\n", res) } } From 506a12c06c6a4c1a2d641afdd5ddcf36ee857f03 Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Sun, 28 Sep 2025 09:21:17 +0200 Subject: [PATCH 06/11] use new PocketBaseClient in microblog package --- internal/microblog/handler.go | 6 +- internal/microblog/microblog.go | 222 ++++++++++++++++---------------- test_pocketbase.go | 6 +- 3 files changed, 119 insertions(+), 115 deletions(-) diff --git a/internal/microblog/handler.go b/internal/microblog/handler.go index 07ceee0..42c1629 100644 --- a/internal/microblog/handler.go +++ b/internal/microblog/handler.go @@ -1,6 +1,8 @@ package microblog import ( + "gemini_site/internal/pocketbase" + gemini "github.com/kulak/gemini" ) @@ -10,6 +12,6 @@ type Handler interface { } // NewHandler creates a new microblog handler -func NewHandler() Handler { - return NewMicroBlog() +func NewHandler(pbClient *pocketbase.PocketBaseClient) Handler { + return NewMicroBlog(pbClient) } diff --git a/internal/microblog/microblog.go b/internal/microblog/microblog.go index b511535..ebdc7d7 100644 --- a/internal/microblog/microblog.go +++ b/internal/microblog/microblog.go @@ -3,8 +3,8 @@ package microblog import ( "encoding/json" "fmt" - "sort" - "strings" + "gemini_site/internal/pocketbase" + "log/slog" "time" gemini "github.com/kulak/gemini" @@ -48,149 +48,149 @@ type pbPost struct { // MicroBlog manages blog posts type MicroBlog struct { - posts []Post + pbClient *pocketbase.PocketBaseClient } // NewMicroBlog creates a new microblog instance -func NewMicroBlog() *MicroBlog { +func NewMicroBlog(pbClient *pocketbase.PocketBaseClient) *MicroBlog { mb := &MicroBlog{ - posts: make([]Post, 0), + pbClient: pbClient, } - - // Add some sample posts - mb.addSamplePosts() - return mb } -// addSamplePosts adds some initial content -func (mb *MicroBlog) addSamplePosts() { - samplePosts := []Post{ - { - ID: "1", - Title: "Welcome to the Gemini Microblog", - Content: "This is the first post on our Gemini-powered microblog! It's simple, fast, and distraction-free.", - Author: "Admin", - Timestamp: time.Now().Add(-2 * time.Hour), - }, - { - ID: "2", - Title: "The Beauty of Simplicity", - Content: "Gemini protocol encourages us to focus on content over presentation. This microblog embodies that philosophy.", - Author: "Admin", - Timestamp: time.Now().Add(-1 * time.Hour), - }, - } +// // Add some sample posts +// mb.addSamplePosts() - mb.posts = append(mb.posts, samplePosts...) -} +// return mb +// } + +// addSamplePosts adds some initial content +// func (mb *MicroBlog) addSamplePosts() { +// samplePosts := []Post{ +// { +// ID: "1", +// Title: "Welcome to the Gemini Microblog", +// Content: "This is the first post on our Gemini-powered microblog! It's simple, fast, and distraction-free.", +// Author: "Admin", +// Timestamp: time.Now().Add(-2 * time.Hour), +// }, +// { +// ID: "2", +// Title: "The Beauty of Simplicity", +// Content: "Gemini protocol encourages us to focus on content over presentation. This microblog embodies that philosophy.", +// Author: "Admin", +// Timestamp: time.Now().Add(-1 * time.Hour), +// }, +// } + +// mb.posts = append(mb.posts, samplePosts...) +// } // AddPost adds a new post to the blog -func (mb *MicroBlog) AddPost(title, content, author string) string { - id := fmt.Sprintf("%d", time.Now().Unix()) - post := Post{ - ID: id, - Title: title, - Content: content, - Author: author, - Timestamp: time.Now(), - } +// func (mb *MicroBlog) AddPost(title, content, author string) string { +// id := fmt.Sprintf("%d", time.Now().Unix()) +// post := Post{ +// ID: id, +// Title: title, +// Content: content, +// Author: author, +// Timestamp: time.Now(), +// } - mb.posts = append(mb.posts, post) - return id -} +// mb.posts = append(mb.posts, post) +// return id +// } // GetPost retrieves a post by ID -func (mb *MicroBlog) GetPost(id string) (*Post, bool) { - for _, post := range mb.posts { - if post.ID == id { - return &post, true - } - } - return nil, false -} +// func (mb *MicroBlog) GetPost(id string) (*Post, bool) { +// for _, post := range mb.posts { +// if post.ID == id { +// return &post, true +// } +// } +// return nil, false +// } // GetRecentPosts returns the most recent posts -func (mb *MicroBlog) GetRecentPosts(limit int) []Post { - // Sort posts by timestamp (newest first) - sortedPosts := make([]Post, len(mb.posts)) - copy(sortedPosts, mb.posts) - - sort.Slice(sortedPosts, func(i, j int) bool { - return sortedPosts[i].Timestamp.After(sortedPosts[j].Timestamp) - }) - - if limit > 0 && len(sortedPosts) > limit { - return sortedPosts[:limit] +func (mb *MicroBlog) GetRecentPosts(limit int) ([]post, error) { + res, err := mb.pbClient.GetList("micro_blog_posts", 1, limit, "-posted") + if err != nil { + return nil, fmt.Errorf("failed to fetch posts from pocketbase: %w", err) } - return sortedPosts + var rawPosts []pbPost + if err := json.Unmarshal(res.Items, &rawPosts); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + slog.Info("Posts from pocketbase", "rawPosts", rawPosts) + return nil, fmt.Errorf("todo: %w", err) } // HandleBlogRequest handles Gemini requests for the microblog func (mb *MicroBlog) HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Request) { - path := req.URL.Path + // path := req.URL.Path - switch { - case path == "/microblog" || path == "/microblog/": - mb.serveBlogIndex(w, req) - case strings.HasPrefix(path, "/microblog/post/"): - postID := strings.TrimPrefix(path, "/microblog/post/") - mb.servePost(w, req, postID) - default: - w.WriteStatusMsg(gemini.StatusNotFound, "Blog page not found") - } + // switch { + // case path == "/microblog" || path == "/microblog/": + // mb.serveBlogIndex(w, req) + // case strings.HasPrefix(path, "/microblog/post/"): + // postID := strings.TrimPrefix(path, "/microblog/post/") + // mb.servePost(w, req, postID) + // default: + // w.WriteStatusMsg(gemini.StatusNotFound, "Blog page not found") + // } } // serveBlogIndex serves the main blog page with recent posts -func (mb *MicroBlog) serveBlogIndex(w gemini.ResponseWriter, req *gemini.Request) { - w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") +// func (mb *MicroBlog) serveBlogIndex(w gemini.ResponseWriter, req *gemini.Request) { +// w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - var content strings.Builder - content.WriteString("# Gemini Microblog\n\n") +// var content strings.Builder +// content.WriteString("# Gemini Microblog\n\n") - posts := mb.GetRecentPosts(10) +// posts := mb.GetRecentPosts(10) - if len(posts) == 0 { - content.WriteString("No posts yet. Be the first to write something!\n\n") - } else { - content.WriteString("## Recent Posts\n\n") +// if len(posts) == 0 { +// content.WriteString("No posts yet. Be the first to write something!\n\n") +// } else { +// content.WriteString("## Recent Posts\n\n") - for _, post := range posts { - content.WriteString(fmt.Sprintf("=> /blog/post/%s %s\n", post.ID, post.Title)) - content.WriteString(fmt.Sprintf(" By %s on %s\n\n", - post.Author, - post.Timestamp.Format("2006-01-02 15:04"))) - } - } +// for _, post := range posts { +// content.WriteString(fmt.Sprintf("=> /blog/post/%s %s\n", post.ID, post.Title)) +// content.WriteString(fmt.Sprintf(" By %s on %s\n\n", +// post.Author, +// post.Timestamp.Format("2006-01-02 15:04"))) +// } +// } - content.WriteString("## Actions\n\n") - content.WriteString("=> /blog/new Write a new post\n") - content.WriteString("=> / Back to home\n") +// content.WriteString("## Actions\n\n") +// content.WriteString("=> /blog/new Write a new post\n") +// content.WriteString("=> / Back to home\n") - w.WriteBody([]byte(content.String())) -} +// w.WriteBody([]byte(content.String())) +// } // servePost serves a single blog post -func (mb *MicroBlog) servePost(w gemini.ResponseWriter, req *gemini.Request, postID string) { - post, found := mb.GetPost(postID) - if !found { - w.WriteStatusMsg(gemini.StatusNotFound, "Post not found") - return - } +// func (mb *MicroBlog) servePost(w gemini.ResponseWriter, req *gemini.Request, postID string) { +// post, found := mb.GetPost(postID) +// if !found { +// w.WriteStatusMsg(gemini.StatusNotFound, "Post not found") +// return +// } - w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") +// w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - var content strings.Builder - content.WriteString(fmt.Sprintf("# %s\n\n", post.Title)) - content.WriteString(fmt.Sprintf("By %s on %s\n\n", - post.Author, - post.Timestamp.Format("2006-01-02 15:04"))) - content.WriteString("---\n\n") - content.WriteString(post.Content) - content.WriteString("\n\n---\n\n") - content.WriteString("=> /blog Back to blog\n") - content.WriteString("=> / Back to home\n") +// var content strings.Builder +// content.WriteString(fmt.Sprintf("# %s\n\n", post.Title)) +// content.WriteString(fmt.Sprintf("By %s on %s\n\n", +// post.Author, +// post.Timestamp.Format("2006-01-02 15:04"))) +// content.WriteString("---\n\n") +// content.WriteString(post.Content) +// content.WriteString("\n\n---\n\n") +// content.WriteString("=> /blog Back to blog\n") +// content.WriteString("=> / Back to home\n") - w.WriteBody([]byte(content.String())) -} +// w.WriteBody([]byte(content.String())) +// } diff --git a/test_pocketbase.go b/test_pocketbase.go index c4aed1a..df5caa4 100644 --- a/test_pocketbase.go +++ b/test_pocketbase.go @@ -4,15 +4,17 @@ import ( "fmt" "log" + "gemini_site/internal/microblog" "gemini_site/internal/pocketbase" ) func main() { pbClient := pocketbase.NewPocketBaseClient() - res, err := pbClient.GetList("micro_blog_posts", 1, 10, "-posted") + mb := microblog.NewMicroBlog(pbClient) + res, err := mb.GetRecentPosts(2) + // res, err := pbClient.GetList("micro_blog_posts", 1, 10, "-posted") fmt.Println("Getting page 1 of microblog posts") // posts, err := client.GetPosts(1) - if err != nil { log.Printf("Error getting posts: %v", err) } else { From ef5f5e33abb6a0b87277289ad48524c795989972 Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Sun, 28 Sep 2025 12:18:33 +0200 Subject: [PATCH 07/11] convert pbPost to post --- internal/microblog/microblog.go | 35 +++++++++++++++++++++++++++------ test_pocketbase.go | 11 +++++------ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/internal/microblog/microblog.go b/internal/microblog/microblog.go index ebdc7d7..09a6ee9 100644 --- a/internal/microblog/microblog.go +++ b/internal/microblog/microblog.go @@ -20,11 +20,12 @@ const ( sourceNostr source = "nostr" ) -var supportedSources = []source{ - sourceNostr, - // TODO: Add support for BlueSky and Mastodon - // SourceBlueSky, - // SourceMastodon, +var supportedSources = map[source]bool{ + sourceNostr: true, + sourcePleroma: false, + sourceBlueSky: false, + sourceMastodon: false, + sourcePixelfed: false, } // Post represents a single blog post @@ -46,6 +47,10 @@ type pbPost struct { Posted string `json:"posted"` } +type nostrPost struct { + Content string `json:"content"` +} + // MicroBlog manages blog posts type MicroBlog struct { pbClient *pocketbase.PocketBaseClient @@ -124,7 +129,25 @@ func (mb *MicroBlog) GetRecentPosts(limit int) ([]post, error) { return nil, fmt.Errorf("failed to decode response: %w", err) } slog.Info("Posts from pocketbase", "rawPosts", rawPosts) - return nil, fmt.Errorf("todo: %w", err) + + var filteredPosts []post + for _, p := range rawPosts { + if p.Source == SourceNostr { + var nostrPost nostrPost + if err := json.Unmarshal(p.FullPost, &nostrPost); err != nil { + slog.Error("Problem unmarshalling nostr post", "error", err) + continue + } + filteredPosts = append(filteredPosts, post{ + ID: p.ID, + RemoteID: p.RemoteID, + Content: nostrPost.Content, + Timestamp: time.Now(), + }) + continue + } + } + return filteredPosts, nil } // HandleBlogRequest handles Gemini requests for the microblog diff --git a/test_pocketbase.go b/test_pocketbase.go index df5caa4..5edddf0 100644 --- a/test_pocketbase.go +++ b/test_pocketbase.go @@ -2,7 +2,7 @@ package main import ( "fmt" - "log" + "log/slog" "gemini_site/internal/microblog" "gemini_site/internal/pocketbase" @@ -11,14 +11,13 @@ import ( func main() { pbClient := pocketbase.NewPocketBaseClient() mb := microblog.NewMicroBlog(pbClient) - res, err := mb.GetRecentPosts(2) + res, err := mb.GetRecentPosts(10) // res, err := pbClient.GetList("micro_blog_posts", 1, 10, "-posted") - fmt.Println("Getting page 1 of microblog posts") + fmt.Println("Getting page 1 of microblog posts, 10 posts") // posts, err := client.GetPosts(1) if err != nil { - log.Printf("Error getting posts: %v", err) + slog.Error("Error getting posts", "error", err) } else { - fmt.Printf("Got first page of microblog posts\n") - fmt.Printf("First post: %v\n", res) + slog.Info("Got microblog posts", "posts", res) } } From ee4dfdad185b2263c3f7cc0ce42e9135f06657c0 Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Sun, 28 Sep 2025 12:42:59 +0200 Subject: [PATCH 08/11] add page for microblog header content --- dev.sh | 4 +- internal/microblog/microblog.go | 146 ++++++++++++++++++++------------ internal/pocketbase/pb.go | 1 + main.go | 4 +- pages/microblog.gmi | 12 +++ 5 files changed, 110 insertions(+), 57 deletions(-) create mode 100644 pages/microblog.gmi diff --git a/dev.sh b/dev.sh index 23a241e..a36cc39 100755 --- a/dev.sh +++ b/dev.sh @@ -2,4 +2,6 @@ set -e -go run main.go -cert=./keys/localhost.crt.pem -key=./keys/localhost_key.pem -host=localhost:8080 +source .env + +fd | entr -r go run main.go -cert=./keys/localhost.crt.pem -key=./keys/localhost_key.pem -host=localhost:8080 diff --git a/internal/microblog/microblog.go b/internal/microblog/microblog.go index 09a6ee9..ff36dc3 100644 --- a/internal/microblog/microblog.go +++ b/internal/microblog/microblog.go @@ -5,6 +5,8 @@ import ( "fmt" "gemini_site/internal/pocketbase" "log/slog" + "os" + "strings" "time" gemini "github.com/kulak/gemini" @@ -20,14 +22,6 @@ const ( sourceNostr source = "nostr" ) -var supportedSources = map[source]bool{ - sourceNostr: true, - sourcePleroma: false, - sourceBlueSky: false, - sourceMastodon: false, - sourcePixelfed: false, -} - // Post represents a single blog post type post struct { ID string @@ -36,6 +30,7 @@ type post struct { // TODO: add support for images, must extend the pocketbase query // Images []string Timestamp time.Time + Source source } // PBPost represents a microblog post from PocketBase @@ -108,14 +103,32 @@ func NewMicroBlog(pbClient *pocketbase.PocketBaseClient) *MicroBlog { // } // GetPost retrieves a post by ID -// func (mb *MicroBlog) GetPost(id string) (*Post, bool) { -// for _, post := range mb.posts { -// if post.ID == id { -// return &post, true -// } -// } -// return nil, false -// } +// +// func (mb *MicroBlog) GetPost(id string) (*Post, bool) { +// for _, post := range mb.posts { +// if post.ID == id { +// return &post, true +// } +// } +// return nil, false +// } +func transformNostrPost(p pbPost) post { + var nostrPost nostrPost + if err := json.Unmarshal(p.FullPost, &nostrPost); err != nil { + slog.Error("Problem unmarshalling nostr post", "error", err) + return post{} + } + content := nostrPost.Content + content = strings.ReplaceAll(content, "#", "\\#") + // content = strings.ReplaceAll(content, "\t", "\\t") + return post{ + ID: p.ID, + RemoteID: p.RemoteID, + Content: content, + Timestamp: time.Now(), + Source: source(p.Source), + } +} // GetRecentPosts returns the most recent posts func (mb *MicroBlog) GetRecentPosts(limit int) ([]post, error) { @@ -133,17 +146,19 @@ func (mb *MicroBlog) GetRecentPosts(limit int) ([]post, error) { var filteredPosts []post for _, p := range rawPosts { if p.Source == SourceNostr { - var nostrPost nostrPost - if err := json.Unmarshal(p.FullPost, &nostrPost); err != nil { - slog.Error("Problem unmarshalling nostr post", "error", err) - continue - } - filteredPosts = append(filteredPosts, post{ - ID: p.ID, - RemoteID: p.RemoteID, - Content: nostrPost.Content, - Timestamp: time.Now(), - }) + filteredPosts = append(filteredPosts, transformNostrPost(p)) + + // var nostrPost nostrPost + // if err := json.Unmarshal(p.FullPost, &nostrPost); err != nil { + // slog.Error("Problem unmarshalling nostr post", "error", err) + // continue + // } + // filteredPosts = append(filteredPosts, post{ + // ID: p.ID, + // RemoteID: p.RemoteID, + // Content: nostrPost.Content, + // Timestamp: time.Now(), + // }) continue } } @@ -152,47 +167,68 @@ func (mb *MicroBlog) GetRecentPosts(limit int) ([]post, error) { // HandleBlogRequest handles Gemini requests for the microblog func (mb *MicroBlog) HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Request) { - // path := req.URL.Path + path := req.URL.Path - // switch { - // case path == "/microblog" || path == "/microblog/": - // mb.serveBlogIndex(w, req) + switch { + case path == "/microblog" || path == "/microblog/": + mb.serveIndex(w, req) // case strings.HasPrefix(path, "/microblog/post/"): // postID := strings.TrimPrefix(path, "/microblog/post/") // mb.servePost(w, req, postID) - // default: - // w.WriteStatusMsg(gemini.StatusNotFound, "Blog page not found") - // } + default: + w.WriteStatusMsg(gemini.StatusNotFound, "Page not found") + } +} + +func drawPost(builder *strings.Builder, p post) { + builder.WriteString("+------------------------------------------+\n") + builder.WriteString(p.Content) + builder.WriteString("\n") + builder.WriteString(fmt.Sprintf("source: %s, id: %s...\n", p.Source, p.RemoteID[:10])) + builder.WriteString("+------------------------------------------+\n\n\n") + + // builder.WriteString(fmt.Sprintf("=> /blog/post/%s %s\n", p.ID, p.Title)) + // builder.WriteString(fmt.Sprintf(" By %s on %s\n\n", + // p.Timestamp.Format("2006-01-02 15:04"))) } // serveBlogIndex serves the main blog page with recent posts -// func (mb *MicroBlog) serveBlogIndex(w gemini.ResponseWriter, req *gemini.Request) { -// w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") +func (mb *MicroBlog) serveIndex(w gemini.ResponseWriter, req *gemini.Request) { + w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") -// var content strings.Builder -// content.WriteString("# Gemini Microblog\n\n") + var content strings.Builder + // content.WriteString("# Gemini Microblog\n\n") + // content.WriteString("Here are my microblog posts from various plantforms\n") + // Read and include the contents of ../../pages/microblog.gmi + page, err := os.ReadFile("./pages/microblog.gmi") + if err != nil { + slog.Error("Problem reading microblog page", "error", err) + return + } + content.Write(page) + content.WriteString("\n") -// posts := mb.GetRecentPosts(10) + posts, err := mb.GetRecentPosts(20) + if err != nil { + content.WriteString("Error fetching posts: " + err.Error() + "\n\n") + } -// if len(posts) == 0 { -// content.WriteString("No posts yet. Be the first to write something!\n\n") -// } else { -// content.WriteString("## Recent Posts\n\n") + if len(posts) == 0 { + content.WriteString("No posts found.\n\n") + } else { + content.WriteString("## Recent Posts\n\n") -// for _, post := range posts { -// content.WriteString(fmt.Sprintf("=> /blog/post/%s %s\n", post.ID, post.Title)) -// content.WriteString(fmt.Sprintf(" By %s on %s\n\n", -// post.Author, -// post.Timestamp.Format("2006-01-02 15:04"))) -// } -// } + for _, post := range posts { + drawPost(&content, post) + } + } -// content.WriteString("## Actions\n\n") -// content.WriteString("=> /blog/new Write a new post\n") -// content.WriteString("=> / Back to home\n") + content.WriteString("## Nav\n\n") + // content.WriteString("=> /blog/new Write a new post\n") + content.WriteString("=> / Back to home\n") -// w.WriteBody([]byte(content.String())) -// } + w.WriteBody([]byte(content.String())) +} // servePost serves a single blog post // func (mb *MicroBlog) servePost(w gemini.ResponseWriter, req *gemini.Request, postID string) { diff --git a/internal/pocketbase/pb.go b/internal/pocketbase/pb.go index c0e1685..e7bfdf8 100644 --- a/internal/pocketbase/pb.go +++ b/internal/pocketbase/pb.go @@ -176,6 +176,7 @@ func (c *PocketBaseClient) GetList( "perPage": {strconv.Itoa(pageSize)}, "sort": {sort}, "skipTotal": {"true"}, + "filter": {"source = \"nostr\""}, // TODO: add additional fields like image and tag? } apiURL := fmt.Sprintf("%s/api/collections/%s/records", c.host, collection) diff --git a/main.go b/main.go index f73ddea..4a93954 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "time" "gemini_site/internal/microblog" + "gemini_site/internal/pocketbase" gemini "github.com/kulak/gemini" ) @@ -102,8 +103,9 @@ func main() { flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file") flag.Parse() + pbClient := pocketbase.NewPocketBaseClient() handler := MainHandler{ - blog: microblog.NewHandler(), + blog: microblog.NewHandler(pbClient), } err := gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini)) diff --git a/pages/microblog.gmi b/pages/microblog.gmi new file mode 100644 index 0000000..45278e0 --- /dev/null +++ b/pages/microblog.gmi @@ -0,0 +1,12 @@ +# Microblog + +An aggregation of tweet like posts from my various social media platforms. + +clearnet version: + +=> https://travisshears.com/micro-blog + +So for it renders posts from: +* nostr, id: nprofile1qyxhwumn8ghj7mn0wvhxcmmvqqs9udcv9uhqggjz87js9rtaph4lajlxnxsvwvm7zwdjt6etzyk52rgeg4wrz +* mastodon, id: dice.camp/@travisshears +* bluesky, id: @travisshears.bsky.social From 444d143a0b7608ded9f29408b96c05fe31bc240d Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Mon, 29 Sep 2025 10:25:16 +0200 Subject: [PATCH 09/11] add pagination to microblog --- internal/microblog/microblog.go | 56 ++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/internal/microblog/microblog.go b/internal/microblog/microblog.go index ff36dc3..7229613 100644 --- a/internal/microblog/microblog.go +++ b/internal/microblog/microblog.go @@ -6,6 +6,8 @@ import ( "gemini_site/internal/pocketbase" "log/slog" "os" + "regexp" + "strconv" "strings" "time" @@ -131,8 +133,8 @@ func transformNostrPost(p pbPost) post { } // GetRecentPosts returns the most recent posts -func (mb *MicroBlog) GetRecentPosts(limit int) ([]post, error) { - res, err := mb.pbClient.GetList("micro_blog_posts", 1, limit, "-posted") +func (mb *MicroBlog) GetRecentPosts(limit int, page int) ([]post, error) { + res, err := mb.pbClient.GetList("micro_blog_posts", page, limit, "-posted") if err != nil { return nil, fmt.Errorf("failed to fetch posts from pocketbase: %w", err) } @@ -165,15 +167,36 @@ func (mb *MicroBlog) GetRecentPosts(limit int) ([]post, error) { return filteredPosts, nil } +func replaceLinks(content string) string { + // Regex: ^(https?://\S+) + // ^ : start of line + // https? : http or https + // :// : literal + // \S+ : one or more non-whitespace characters + re := regexp.MustCompile(`(?m)^(https?://\S+)`) + // Replace each match with "=> " + return re.ReplaceAllStringFunc(content, func(match string) string { + return "=> " + match + }) +} + // HandleBlogRequest handles Gemini requests for the microblog func (mb *MicroBlog) HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Request) { path := req.URL.Path switch { case path == "/microblog" || path == "/microblog/": - mb.serveIndex(w, req) + mb.serveIndex(w, req, 1) + case strings.HasPrefix(path, "/microblog/page/"): + pageNum := strings.TrimPrefix(path, "/microblog/page/") + num, err := strconv.Atoi(pageNum) + if err != nil { + w.WriteStatusMsg(gemini.StatusBadRequest, "Invalid page number") + return + } + mb.serveIndex(w, req, num) // case strings.HasPrefix(path, "/microblog/post/"): - // postID := strings.TrimPrefix(path, "/microblog/post/") + // postID := strings.TrimPrefix(path, "/microblog/post/") // mb.servePost(w, req, postID) default: w.WriteStatusMsg(gemini.StatusNotFound, "Page not found") @@ -182,7 +205,8 @@ func (mb *MicroBlog) HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Requ func drawPost(builder *strings.Builder, p post) { builder.WriteString("+------------------------------------------+\n") - builder.WriteString(p.Content) + content := replaceLinks(p.Content) + builder.WriteString(content) builder.WriteString("\n") builder.WriteString(fmt.Sprintf("source: %s, id: %s...\n", p.Source, p.RemoteID[:10])) builder.WriteString("+------------------------------------------+\n\n\n") @@ -193,22 +217,24 @@ func drawPost(builder *strings.Builder, p post) { } // serveBlogIndex serves the main blog page with recent posts -func (mb *MicroBlog) serveIndex(w gemini.ResponseWriter, req *gemini.Request) { +func (mb *MicroBlog) serveIndex(w gemini.ResponseWriter, req *gemini.Request, pageNum int) { w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") var content strings.Builder // content.WriteString("# Gemini Microblog\n\n") // content.WriteString("Here are my microblog posts from various plantforms\n") // Read and include the contents of ../../pages/microblog.gmi - page, err := os.ReadFile("./pages/microblog.gmi") - if err != nil { - slog.Error("Problem reading microblog page", "error", err) - return + if pageNum == 1 { + page, err := os.ReadFile("./pages/microblog.gmi") + if err != nil { + slog.Error("Problem reading microblog page", "error", err) + return + } + content.Write(page) + content.WriteString("\n") } - content.Write(page) - content.WriteString("\n") - posts, err := mb.GetRecentPosts(20) + posts, err := mb.GetRecentPosts(20, pageNum) if err != nil { content.WriteString("Error fetching posts: " + err.Error() + "\n\n") } @@ -216,7 +242,7 @@ func (mb *MicroBlog) serveIndex(w gemini.ResponseWriter, req *gemini.Request) { if len(posts) == 0 { content.WriteString("No posts found.\n\n") } else { - content.WriteString("## Recent Posts\n\n") + content.WriteString("## Posts\n\n") for _, post := range posts { drawPost(&content, post) @@ -225,6 +251,8 @@ func (mb *MicroBlog) serveIndex(w gemini.ResponseWriter, req *gemini.Request) { content.WriteString("## Nav\n\n") // content.WriteString("=> /blog/new Write a new post\n") + + content.WriteString(fmt.Sprintf("=> /microblog/page/%d Next page\n", pageNum+1)) content.WriteString("=> / Back to home\n") w.WriteBody([]byte(content.String())) From 641f2620977701800ccfde02fdcbed888566bdbb Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Mon, 29 Sep 2025 10:32:32 +0200 Subject: [PATCH 10/11] add microblog to site updates --- pages/home.gmi | 10 +++++++++- pages/microblog.gmi | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pages/home.gmi b/pages/home.gmi index f7d0eb2..c46207d 100644 --- a/pages/home.gmi +++ b/pages/home.gmi @@ -20,10 +20,18 @@ So far I've joined the following communities here in gemspace: ## Capsule Features -=> /microblog Microblog - Aggregation of all my microblog posts from Mastodon, BlueSky, and Nostr. +=> /microblog Microblog - Aggregation of all my microblog posts ## Site updates +== 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. + +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 == Initial deployment diff --git a/pages/microblog.gmi b/pages/microblog.gmi index 45278e0..691f0d3 100644 --- a/pages/microblog.gmi +++ b/pages/microblog.gmi @@ -8,5 +8,7 @@ clearnet version: So for it renders posts from: * nostr, id: nprofile1qyxhwumn8ghj7mn0wvhxcmmvqqs9udcv9uhqggjz87js9rtaph4lajlxnxsvwvm7zwdjt6etzyk52rgeg4wrz + +I plan to add more platforms in the future: * mastodon, id: dice.camp/@travisshears * bluesky, id: @travisshears.bsky.social From 93cdcef885e63e253ef6c860d9b7271373988a6e Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Mon, 29 Sep 2025 10:33:55 +0200 Subject: [PATCH 11/11] fix docker container --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index fd8de56..0ea5a23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY main.go . +COPY internal ./internal RUN go build -o main .