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
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()))
}

View file

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

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
}