diff --git a/.env b/.env new file mode 100644 index 0000000..e48c393 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +export POCKET_BASE_PW=ifk2HEbM +export POCKET_BASE_HOST=http://aemos:5000 +export POCKET_BASE_USER=micro_blog_fetchers diff --git a/.gitignore b/.gitignore index a82f26a..1ab3e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ keys/ gemini-server +gemini_site diff --git a/go.mod b/go.mod index 61f416f..a5895a6 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,6 @@ module gemini_site go 1.25.0 -require github.com/kulak/gemini v1.2.2 +require ( + github.com/kulak/gemini v1.2.2 +) diff --git a/internal/microblog/microblog.go b/internal/microblog/microblog.go index b74c15a..d9e69c8 100644 --- a/internal/microblog/microblog.go +++ b/internal/microblog/microblog.go @@ -190,19 +190,3 @@ func (mb *MicroBlog) serveNewPostForm(w gemini.ResponseWriter, req *gemini.Reque w.WriteBody([]byte(content.String())) } - -// parsePostData parses the post data from Titan payload -func (mb *MicroBlog) parsePostData(data string) (title, content string) { - lines := strings.Split(data, "\n") - - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, "Title:") { - title = strings.TrimSpace(strings.TrimPrefix(line, "Title:")) - } else if strings.HasPrefix(line, "Content:") { - content = strings.TrimSpace(strings.TrimPrefix(line, "Content:")) - } - } - - return title, content -} diff --git a/internal/microblog/pocketbase.go b/internal/microblog/pocketbase.go new file mode 100644 index 0000000..0f7a16e --- /dev/null +++ b/internal/microblog/pocketbase.go @@ -0,0 +1,227 @@ +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 +} diff --git a/internal/microblog/pocketbase_example.go b/internal/microblog/pocketbase_example.go new file mode 100644 index 0000000..abccb34 --- /dev/null +++ b/internal/microblog/pocketbase_example.go @@ -0,0 +1,19 @@ +package microblog + +import ( + "fmt" + "log" +) + +func main() { + client := NewPocketBaseClient() + fmt.Println("Getting page 1 of microblog posts") + posts, err := client.GetPosts(1) + if err != nil { + log.Printf("Error getting posts: %v", err) + } else { + fmt.Printf("Got first page of microblog posts\n") + fmt.Printf("First post: %v\n", posts[0]) + } + +} diff --git a/test_pocketbase.go b/test_pocketbase.go new file mode 100644 index 0000000..8d5c64f --- /dev/null +++ b/test_pocketbase.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "log" + + "gemini_site/internal/microblog" +) + +func main() { + client := microblog.NewPocketBaseClient() + fmt.Println("Getting page 1 of microblog posts") + posts, err := client.GetPosts(1) + if err != nil { + log.Printf("Error getting posts: %v", err) + } else { + fmt.Printf("Got first page of microblog posts\n") + fmt.Printf("First post: %v\n", posts[0]) + } +}