create seprate pocketbase client

This commit is contained in:
Travis Shears 2025-09-27 14:39:18 +02:00
parent 654df98711
commit 2837293474
6 changed files with 245 additions and 43 deletions

View file

@ -1,6 +1,7 @@
package microblog package microblog
import ( import (
"encoding/json"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
@ -9,15 +10,42 @@ import (
gemini "github.com/kulak/gemini" 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 // Post represents a single blog post
type Post struct { type post struct {
ID string ID string
Title string RemoteID string
Content string Content string
Author string // TODO: add support for images, must extend the pocketbase query
// Images []string
Timestamp time.Time 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 // MicroBlog manages blog posts
type MicroBlog struct { type MicroBlog struct {
posts []Post posts []Post
@ -104,13 +132,11 @@ func (mb *MicroBlog) HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Requ
path := req.URL.Path path := req.URL.Path
switch { switch {
case path == "/blog" || path == "/blog/": case path == "/microblog" || path == "/microblog/":
mb.serveBlogIndex(w, req) mb.serveBlogIndex(w, req)
case strings.HasPrefix(path, "/blog/post/"): case strings.HasPrefix(path, "/microblog/post/"):
postID := strings.TrimPrefix(path, "/blog/post/") postID := strings.TrimPrefix(path, "/microblog/post/")
mb.servePost(w, req, postID) mb.servePost(w, req, postID)
case path == "/blog/new":
mb.serveNewPostForm(w, req)
default: default:
w.WriteStatusMsg(gemini.StatusNotFound, "Blog page not found") 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())) 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()))
}

View file

@ -221,7 +221,7 @@ func (c *PocketBaseClient) GetPosts(page int) ([]PBPost, error) {
if err := json.NewDecoder(resp.Body).Decode(&postsResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&postsResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err) 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 return postsResp.Items, nil
} }

196
internal/pocketbase/pb.go Normal file
View file

@ -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
}

View file

@ -15,17 +15,17 @@ import (
gemini "github.com/kulak/gemini" gemini "github.com/kulak/gemini"
) )
type ExampleHandler struct { type MainHandler struct {
blog microblog.Handler 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", slog.Info("gemini request",
"path", req.URL.Path, "path", req.URL.Path,
"user", strings.Join(userName(req), " ")) "user", strings.Join(userName(req), " "))
// Check if this is a blog request // 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) h.blog.HandleBlogRequest(w, req)
return return
} }
@ -102,7 +102,7 @@ func main() {
flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file") flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file")
flag.Parse() flag.Parse()
handler := ExampleHandler{ handler := MainHandler{
blog: microblog.NewHandler(), blog: microblog.NewHandler(),
} }

View file

@ -18,9 +18,9 @@ My personal website:
So far I've joined the following communities here in gemspace: So far I've joined the following communities here in gemspace:
=> gemini://station.martinrue.com/travisshears => 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 ## Site updates

View file

@ -4,17 +4,19 @@ import (
"fmt" "fmt"
"log" "log"
"gemini_site/internal/microblog" "gemini_site/internal/pocketbase"
) )
func main() { 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") fmt.Println("Getting page 1 of microblog posts")
posts, err := client.GetPosts(1) // posts, err := client.GetPosts(1)
if err != nil { if err != nil {
log.Printf("Error getting posts: %v", err) log.Printf("Error getting posts: %v", err)
} else { } else {
fmt.Printf("Got first page of microblog posts\n") fmt.Printf("Got first page of microblog posts\n")
fmt.Printf("First post: %v\n", posts[0]) fmt.Printf("First post: %v\n", res)
} }
} }