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 81bbbae..1ab3e6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ keys/ +gemini-server +gemini_site diff --git a/Dockerfile b/Dockerfile index 0910dbb..0ea5a23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ COPY go.mod go.sum ./ RUN go mod download COPY main.go . +COPY internal ./internal RUN go build -o main . @@ -22,6 +23,7 @@ WORKDIR /app COPY --from=builder /app/main . COPY keys ./keys +COPY pages ./pages EXPOSE 8080 diff --git a/dev.sh b/dev.sh index 947d997..a36cc39 100755 --- a/dev.sh +++ b/dev.sh @@ -2,4 +2,6 @@ set -e -go run main.go -cert=./keys/cert.crt.pem -key=./keys/localhost_key.pem -host=localhost:8080 +source .env + +fd | entr -r go run main.go -cert=./keys/localhost.crt.pem -key=./keys/localhost_key.pem -host=localhost:8080 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/handler.go b/internal/microblog/handler.go new file mode 100644 index 0000000..42c1629 --- /dev/null +++ b/internal/microblog/handler.go @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..7229613 --- /dev/null +++ b/internal/microblog/microblog.go @@ -0,0 +1,283 @@ +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 new file mode 100644 index 0000000..15ace81 --- /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/internal/pocketbase/pb.go b/internal/pocketbase/pb.go new file mode 100644 index 0000000..e7bfdf8 --- /dev/null +++ b/internal/pocketbase/pb.go @@ -0,0 +1,197 @@ +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 +} diff --git a/main.go b/main.go index e139a6b..4a93954 100644 --- a/main.go +++ b/main.go @@ -4,19 +4,33 @@ import ( "errors" "flag" "fmt" - "log" + "log/slog" + "os" "strconv" "strings" "time" + "gemini_site/internal/microblog" + "gemini_site/internal/pocketbase" + gemini "github.com/kulak/gemini" ) -type ExampleHandler struct { +type MainHandler struct { + blog microblog.Handler } -func (h ExampleHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { - log.Printf("request: %s, user: %v", req.URL.Path, strings.Join(userName(req), " ")) +func (h MainHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) { + slog.Info("gemini request", + "path", req.URL.Path, + "user", strings.Join(userName(req), " ")) + + // Check if this is a blog request + if strings.HasPrefix(req.URL.Path, "/microblog") { + h.blog.HandleBlogRequest(w, req) + return + } + switch req.URL.Path { case "/": gemini.ServeFileName("pages/home.gmi", "text/gemini")(w, req) @@ -76,17 +90,27 @@ func userName(r *gemini.Request) []string { } func main() { - println("Starting gemini server") + // Set up structured JSON logging + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(logger) + + slog.Info("Starting gemini server") var host, cert, key string flag.StringVar(&host, "host", ":1965", "listen on host and port. Example: hostname:1965") flag.StringVar(&cert, "cert", "server.crt.pem", "certificate file") flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file") flag.Parse() - handler := ExampleHandler{} + pbClient := pocketbase.NewPocketBaseClient() + handler := MainHandler{ + blog: microblog.NewHandler(pbClient), + } err := gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini)) if err != nil { - log.Fatal(err) + slog.Error("server failed to start", "error", err) + os.Exit(1) } } diff --git a/pages/home.gmi b/pages/home.gmi index 6a1a03e..c46207d 100644 --- a/pages/home.gmi +++ b/pages/home.gmi @@ -18,8 +18,20 @@ My personal website: So far I've joined the following communities here in gemspace: => gemini://station.martinrue.com/travisshears +## Capsule Features + +=> /microblog Microblog - Aggregation of all my microblog posts + ## Site updates +== 29.09.2025 == + +Added microblog feature rendering my nostr post feed. Nice to have a dynamic source of content in this capsule to keep it fresh between updates. + +Ideas for next features: +* Guestbook backed by sqlite. Would be fun to playout sqlite and golang. Could also be intresting to do a telegram notification. +* Code block rendering from my git hosting + == 26.09.2025 == Initial deployment @@ -48,3 +60,5 @@ Took quite some tinkering to get everything working, but it's finally up and run You can find the source code for this capsule here: => https://git.travisshears.com/travisshears/personal-gemini-capsule +Diagram created with: +=> https://asciiflow.com diff --git a/pages/microblog.gmi b/pages/microblog.gmi new file mode 100644 index 0000000..691f0d3 --- /dev/null +++ b/pages/microblog.gmi @@ -0,0 +1,14 @@ +# Microblog + +An aggregation of tweet like posts from my various social media platforms. + +clearnet version: + +=> https://travisshears.com/micro-blog + +So for it renders posts from: +* nostr, id: nprofile1qyxhwumn8ghj7mn0wvhxcmmvqqs9udcv9uhqggjz87js9rtaph4lajlxnxsvwvm7zwdjt6etzyk52rgeg4wrz + +I plan to add more platforms in the future: +* mastodon, id: dice.camp/@travisshears +* bluesky, id: @travisshears.bsky.social diff --git a/test_pocketbase.go b/test_pocketbase.go new file mode 100644 index 0000000..5edddf0 --- /dev/null +++ b/test_pocketbase.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "log/slog" + + "gemini_site/internal/microblog" + "gemini_site/internal/pocketbase" +) + +func main() { + pbClient := pocketbase.NewPocketBaseClient() + mb := microblog.NewMicroBlog(pbClient) + res, err := mb.GetRecentPosts(10) + // res, err := pbClient.GetList("micro_blog_posts", 1, 10, "-posted") + fmt.Println("Getting page 1 of microblog posts, 10 posts") + // posts, err := client.GetPosts(1) + if err != nil { + slog.Error("Error getting posts", "error", err) + } else { + slog.Info("Got microblog posts", "posts", res) + } +}