package microblog import ( "bytes" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "os" "strconv" "sync" "time" ) // Source represents the different social media sources type Source string const ( SourcePleroma Source = "pleroma" SourceBlueSky Source = "blue_sky" SourceMastodon Source = "mastodon" SourcePixelfed Source = "pixelfed" SourceNostr Source = "nostr" ) // IsTech represents whether a post is tech-related type IsTech string const ( IsTechYesAI IsTech = "yes_ai" IsTechNo IsTech = "no" IsTechYesHuman IsTech = "yes_human" ) // 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"` IsTech IsTech `json:"isTech"` } // PostsResponse represents the response from PocketBase API type PostsResponse struct { Items []PBPost `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) } // GetAllPostsBySource retrieves all posts for a given source with pagination func (c *PocketBaseClient) GetPosts(page int) ([]PBPost, error) { slog.Info("Getting microblog posts", "page", page) pageSize := 15 params := url.Values{ "page": {strconv.Itoa(page)}, "perPage": {strconv.Itoa(pageSize)}, "sort": {"-posted"}, "skipTotal": {"true"}, // TODO: add additional fields like image and tag? } apiURL := fmt.Sprintf("%s/api/collections/micro_blog_posts/records", c.host) resp, err := c.makeAuthenticatedRequest("GET", apiURL, params) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() 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 postsResp PostsResponse 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) return postsResp.Items, nil }