From 2837293474be9329ebe5018296427bcd6550b0be Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Sat, 27 Sep 2025 14:39:18 +0200 Subject: [PATCH] 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) } }