package microblog import ( _ "embed" "encoding/json" "fmt" "gemini_site/internal/pocketbase" "log/slog" "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 } func escapeHashTags(content string) string { // TODO: maybe prettier way to do this with a code return strings.ReplaceAll(content, "#", "\\#") } func removeHTMLTags(content string) string { // Strip simple HTML tags. Paragraphs become newlines. // Replace opening

tags with nothing, closing

with newline. rePStart := regexp.MustCompile(`(?i)]*>`) rePEnd := regexp.MustCompile(`(?i)

`) content = rePStart.ReplaceAllString(content, "") content = rePEnd.ReplaceAllString(content, "\n") // Convert
to newline reBr := regexp.MustCompile(`(?i)`) content = reBr.ReplaceAllString(content, "\n") // Remove any remaining tags reTags := regexp.MustCompile(`(?i)<[^>]+>`) content = reTags.ReplaceAllString(content, "") // Normalize whitespace: trim and collapse excessive blank lines content = strings.TrimSpace(content) reMultiNewlines := regexp.MustCompile(`\n{3,}`) return reMultiNewlines.ReplaceAllString(content, "\n\n") } 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 = escapeHashTags(content) return post{ ID: p.ID, RemoteID: p.RemoteID, Content: content, Timestamp: time.Now(), Source: source(p.Source), } } func transformBlueSkyPost(p pbPost) post { var fullPost struct { Record struct { Text string `json:"text"` } `json:"record"` } if err := json.Unmarshal(p.FullPost, &fullPost); err != nil { slog.Error("Problem unmarshalling bluesky post", "error", err) return post{} } content := fullPost.Record.Text content = escapeHashTags(content) return post{ ID: p.ID, RemoteID: p.RemoteID, Content: content, Timestamp: time.Now(), Source: source(p.Source), } } func transformMastodonPost(p pbPost) post { var fullPost struct { Content string `json:"content"` Reblog *struct { Content string `json:"content"` Account *struct { Acct string `json:"acct"` } `json:"account"` } `json:"reblog"` } var content string if err := json.Unmarshal(p.FullPost, &fullPost); err != nil { slog.Error("Problem unmarshalling mastodon post", "error", err) return post{} } if fullPost.Reblog != nil { content = fmt.Sprintf( "reblogged post from %s:\n%s\n", fullPost.Reblog.Account.Acct, fullPost.Reblog.Content, ) } else { content = fullPost.Content } content = escapeHashTags(content) content = removeHTMLTags(content) 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) GetPost(id string) (post, error) { res, err := mb.pbClient.GetRecord("micro_blog_posts", id) if err != nil { return post{}, fmt.Errorf("failed to fetch post from pocketbase: %w", err) } var remotePost pbPost if err := json.Unmarshal(*res, &remotePost); err != nil { return post{}, fmt.Errorf("failed to decode response: %w", err) } slog.Info("post from pocketbase", "remotePost", remotePost) switch remotePost.Source { case SourceMastodon: return transformMastodonPost(remotePost), nil case SourceNostr: return transformNostrPost(remotePost), nil case SourceBlueSky: return transformBlueSkyPost(remotePost), nil } return post{}, fmt.Errorf("Don't know how to transform a post with that 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", "count", len(rawPosts)) var filteredPosts []post for _, p := range rawPosts { switch p.Source { case SourceNostr: filteredPosts = append(filteredPosts, transformNostrPost(p)) case SourceMastodon: filteredPosts = append(filteredPosts, transformMastodonPost(p)) case SourceBlueSky: filteredPosts = append(filteredPosts, transformBlueSkyPost(p)) } } return filteredPosts, nil } func formatContent(content string) string { return replaceLinks(content) } 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(fmt.Sprintf("=> /microblog/post/%s 🖋️ %s post: %s \n", p.ID, p.Source, p.ID)) builder.WriteString(formatContent(p.Content)) builder.WriteString(fmt.Sprintf("\nposted: %s\n", p.Timestamp.Format("2006-01-02 15:04"))) builder.WriteString("\n\n") } //go:embed microblog.gmi var pageContnet string // 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 if pageNum == 1 { content.Write([]byte(pageContnet)) 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, err := mb.GetPost(postID) if err != nil { slog.Error("Problem getting post", "error", err) w.WriteStatusMsg(gemini.StatusNotFound, "Problem getting post") return } w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") var content strings.Builder // content.WriteString(fmt.Sprintf("# %s\n\n", post.Title)) drawPost(&content, post) content.WriteString("=> /microblog Back to posts\n") content.WriteString("=> / Back to home\n") w.WriteBody([]byte(content.String())) }