personal-gemini-capsule/internal/pocketbase/pb.go

197 lines
5 KiB
Go

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"},
"filter": {"source = \"nostr\""},
// 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
}