diff --git a/go.mod b/go.mod index 7697495..affbe89 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.25.0 require ( github.com/charmbracelet/bubbletea v1.3.10 - github.com/kulak/gemini v1.2.2 ) require ( diff --git a/internal/microblog/handler.go b/internal/microblog/handler.go deleted file mode 100644 index 42c1629..0000000 --- a/internal/microblog/handler.go +++ /dev/null @@ -1,17 +0,0 @@ -package microblog - -import ( - "gemini_site/internal/pocketbase" - - gemini "github.com/kulak/gemini" -) - -// Handler interface for microblog functionality -type Handler interface { - HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Request) -} - -// NewHandler creates a new microblog handler -func NewHandler(pbClient *pocketbase.PocketBaseClient) Handler { - return NewMicroBlog(pbClient) -} diff --git a/internal/microblog/microblog.go b/internal/microblog/microblog.go deleted file mode 100644 index 7229613..0000000 --- a/internal/microblog/microblog.go +++ /dev/null @@ -1,283 +0,0 @@ -package microblog - -import ( - "encoding/json" - "fmt" - "gemini_site/internal/pocketbase" - "log/slog" - "os" - "regexp" - "strconv" - "strings" - "time" - - 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" -) - -// Post represents a single blog post -type post struct { - ID string - RemoteID string - Content string - // TODO: add support for images, must extend the pocketbase query - // Images []string - Timestamp time.Time - Source source -} - -// 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"` -} - -type nostrPost struct { - Content string `json:"content"` -} - -// MicroBlog manages blog posts -type MicroBlog struct { - pbClient *pocketbase.PocketBaseClient -} - -// NewMicroBlog creates a new microblog instance -func NewMicroBlog(pbClient *pocketbase.PocketBaseClient) *MicroBlog { - mb := &MicroBlog{ - pbClient: pbClient, - } - return mb -} - -// // Add some sample posts -// mb.addSamplePosts() - -// return mb -// } - -// addSamplePosts adds some initial content -// func (mb *MicroBlog) addSamplePosts() { -// samplePosts := []Post{ -// { -// ID: "1", -// Title: "Welcome to the Gemini Microblog", -// Content: "This is the first post on our Gemini-powered microblog! It's simple, fast, and distraction-free.", -// Author: "Admin", -// Timestamp: time.Now().Add(-2 * time.Hour), -// }, -// { -// ID: "2", -// Title: "The Beauty of Simplicity", -// Content: "Gemini protocol encourages us to focus on content over presentation. This microblog embodies that philosophy.", -// Author: "Admin", -// Timestamp: time.Now().Add(-1 * time.Hour), -// }, -// } - -// mb.posts = append(mb.posts, samplePosts...) -// } - -// AddPost adds a new post to the blog -// func (mb *MicroBlog) AddPost(title, content, author string) string { -// id := fmt.Sprintf("%d", time.Now().Unix()) -// post := Post{ -// ID: id, -// Title: title, -// Content: content, -// Author: author, -// Timestamp: time.Now(), -// } - -// mb.posts = append(mb.posts, post) -// return id -// } - -// GetPost retrieves a post by ID -// -// func (mb *MicroBlog) GetPost(id string) (*Post, bool) { -// for _, post := range mb.posts { -// if post.ID == id { -// return &post, true -// } -// } -// return nil, false -// } -func transformNostrPost(p pbPost) post { - var nostrPost nostrPost - if err := json.Unmarshal(p.FullPost, &nostrPost); err != nil { - slog.Error("Problem unmarshalling nostr post", "error", err) - return post{} - } - content := nostrPost.Content - content = strings.ReplaceAll(content, "#", "\\#") - // content = strings.ReplaceAll(content, "\t", "\\t") - return post{ - ID: p.ID, - RemoteID: p.RemoteID, - Content: content, - Timestamp: time.Now(), - Source: source(p.Source), - } -} - -// GetRecentPosts returns the most recent posts -func (mb *MicroBlog) GetRecentPosts(limit int, page int) ([]post, error) { - res, err := mb.pbClient.GetList("micro_blog_posts", page, limit, "-posted") - if err != nil { - return nil, fmt.Errorf("failed to fetch posts from pocketbase: %w", err) - } - - var rawPosts []pbPost - if err := json.Unmarshal(res.Items, &rawPosts); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - slog.Info("Posts from pocketbase", "rawPosts", rawPosts) - - var filteredPosts []post - for _, p := range rawPosts { - if p.Source == SourceNostr { - filteredPosts = append(filteredPosts, transformNostrPost(p)) - - // var nostrPost nostrPost - // if err := json.Unmarshal(p.FullPost, &nostrPost); err != nil { - // slog.Error("Problem unmarshalling nostr post", "error", err) - // continue - // } - // filteredPosts = append(filteredPosts, post{ - // ID: p.ID, - // RemoteID: p.RemoteID, - // Content: nostrPost.Content, - // Timestamp: time.Now(), - // }) - continue - } - } - return filteredPosts, nil -} - -func replaceLinks(content string) string { - // Regex: ^(https?://\S+) - // ^ : start of line - // https? : http or https - // :// : literal - // \S+ : one or more non-whitespace characters - re := regexp.MustCompile(`(?m)^(https?://\S+)`) - // Replace each match with "=> " - return re.ReplaceAllStringFunc(content, func(match string) string { - return "=> " + match - }) -} - -// HandleBlogRequest handles Gemini requests for the microblog -func (mb *MicroBlog) HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Request) { - path := req.URL.Path - - switch { - case path == "/microblog" || path == "/microblog/": - mb.serveIndex(w, req, 1) - case strings.HasPrefix(path, "/microblog/page/"): - pageNum := strings.TrimPrefix(path, "/microblog/page/") - num, err := strconv.Atoi(pageNum) - if err != nil { - w.WriteStatusMsg(gemini.StatusBadRequest, "Invalid page number") - return - } - mb.serveIndex(w, req, num) - // case strings.HasPrefix(path, "/microblog/post/"): - // postID := strings.TrimPrefix(path, "/microblog/post/") - // mb.servePost(w, req, postID) - default: - w.WriteStatusMsg(gemini.StatusNotFound, "Page not found") - } -} - -func drawPost(builder *strings.Builder, p post) { - builder.WriteString("+------------------------------------------+\n") - content := replaceLinks(p.Content) - builder.WriteString(content) - builder.WriteString("\n") - builder.WriteString(fmt.Sprintf("source: %s, id: %s...\n", p.Source, p.RemoteID[:10])) - builder.WriteString("+------------------------------------------+\n\n\n") - - // builder.WriteString(fmt.Sprintf("=> /blog/post/%s %s\n", p.ID, p.Title)) - // builder.WriteString(fmt.Sprintf(" By %s on %s\n\n", - // p.Timestamp.Format("2006-01-02 15:04"))) -} - -// serveBlogIndex serves the main blog page with recent posts -func (mb *MicroBlog) serveIndex(w gemini.ResponseWriter, req *gemini.Request, pageNum int) { - w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - - var content strings.Builder - // content.WriteString("# Gemini Microblog\n\n") - // content.WriteString("Here are my microblog posts from various plantforms\n") - // Read and include the contents of ../../pages/microblog.gmi - if pageNum == 1 { - page, err := os.ReadFile("./pages/microblog.gmi") - if err != nil { - slog.Error("Problem reading microblog page", "error", err) - return - } - content.Write(page) - content.WriteString("\n") - } - - posts, err := mb.GetRecentPosts(20, pageNum) - if err != nil { - content.WriteString("Error fetching posts: " + err.Error() + "\n\n") - } - - if len(posts) == 0 { - content.WriteString("No posts found.\n\n") - } else { - content.WriteString("## Posts\n\n") - - for _, post := range posts { - drawPost(&content, post) - } - } - - content.WriteString("## Nav\n\n") - // content.WriteString("=> /blog/new Write a new post\n") - - content.WriteString(fmt.Sprintf("=> /microblog/page/%d Next page\n", pageNum+1)) - content.WriteString("=> / Back to home\n") - - w.WriteBody([]byte(content.String())) -} - -// servePost serves a single blog post -// func (mb *MicroBlog) servePost(w gemini.ResponseWriter, req *gemini.Request, postID string) { -// post, found := mb.GetPost(postID) -// if !found { -// w.WriteStatusMsg(gemini.StatusNotFound, "Post not found") -// return -// } - -// w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") - -// var content strings.Builder -// content.WriteString(fmt.Sprintf("# %s\n\n", post.Title)) -// content.WriteString(fmt.Sprintf("By %s on %s\n\n", -// post.Author, -// post.Timestamp.Format("2006-01-02 15:04"))) -// content.WriteString("---\n\n") -// content.WriteString(post.Content) -// content.WriteString("\n\n---\n\n") -// content.WriteString("=> /blog Back to blog\n") -// content.WriteString("=> / Back to home\n") - -// w.WriteBody([]byte(content.String())) -// } diff --git a/internal/microblog/pocketbase.go b/internal/microblog/pocketbase.go deleted file mode 100644 index 15ace81..0000000 --- a/internal/microblog/pocketbase.go +++ /dev/null @@ -1,227 +0,0 @@ -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 deleted file mode 100644 index abccb34..0000000 --- a/internal/microblog/pocketbase_example.go +++ /dev/null @@ -1,19 +0,0 @@ -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/internal/pocketbase/pb.go b/internal/pocketbase/pb.go deleted file mode 100644 index e7bfdf8..0000000 --- a/internal/pocketbase/pb.go +++ /dev/null @@ -1,197 +0,0 @@ -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 -}