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\" || source = \"mastodon\" || source = \"blue_sky\""}, // 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 } func (c *PocketBaseClient) GetRecord( collection string, id string, ) (*[]byte, error) { slog.Info( "Getting single record from pocketbase", "recordId", id) apiURL := fmt.Sprintf("%s/api/collections/%s/records/%s", c.host, collection, id) resp, err := c.makeAuthenticatedRequest("GET", apiURL, nil) 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)) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } defer resp.Body.Close() var res pbRes if err := json.Unmarshal(body, &res); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } return &body, nil }