clean up internal dir

This commit is contained in:
Travis Shears 2025-09-30 11:06:33 +02:00
parent 969a057851
commit d1f4527b1b
6 changed files with 0 additions and 744 deletions

1
go.mod
View file

@ -4,7 +4,6 @@ go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/kulak/gemini v1.2.2
)
require (

View file

@ -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)
}

View file

@ -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 "=> <url>"
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()))
// }

View file

@ -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
}

View file

@ -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])
}
}

View file

@ -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
}