Compare commits
No commits in common. "93cdcef885e63e253ef6c860d9b7271373988a6e" and "e1c14af9b7f37a229e4172f9b865d78104565b97" have entirely different histories.
93cdcef885
...
e1c14af9b7
14 changed files with 9 additions and 838 deletions
3
.env
3
.env
|
|
@ -1,3 +0,0 @@
|
||||||
export POCKET_BASE_PW=ifk2HEbM
|
|
||||||
export POCKET_BASE_HOST=http://aemos:5000
|
|
||||||
export POCKET_BASE_USER=micro_blog_fetchers
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,3 +1 @@
|
||||||
keys/
|
keys/
|
||||||
gemini-server
|
|
||||||
gemini_site
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY main.go .
|
COPY main.go .
|
||||||
COPY internal ./internal
|
|
||||||
|
|
||||||
RUN go build -o main .
|
RUN go build -o main .
|
||||||
|
|
||||||
|
|
@ -23,7 +22,6 @@ WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/main .
|
COPY --from=builder /app/main .
|
||||||
COPY keys ./keys
|
COPY keys ./keys
|
||||||
COPY pages ./pages
|
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
|
|
|
||||||
4
dev.sh
4
dev.sh
|
|
@ -2,6 +2,4 @@
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
source .env
|
go run main.go -cert=./keys/cert.crt.pem -key=./keys/localhost_key.pem -host=localhost:8080
|
||||||
|
|
||||||
fd | entr -r go run main.go -cert=./keys/localhost.crt.pem -key=./keys/localhost_key.pem -host=localhost:8080
|
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -2,6 +2,4 @@ module gemini_site
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require github.com/kulak/gemini v1.2.2
|
||||||
github.com/kulak/gemini v1.2.2
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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()))
|
|
||||||
// }
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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])
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
38
main.go
38
main.go
|
|
@ -4,33 +4,19 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gemini_site/internal/microblog"
|
|
||||||
"gemini_site/internal/pocketbase"
|
|
||||||
|
|
||||||
gemini "github.com/kulak/gemini"
|
gemini "github.com/kulak/gemini"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MainHandler struct {
|
type ExampleHandler struct {
|
||||||
blog microblog.Handler
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h ExampleHandler) ServeGemini(w gemini.ResponseWriter, req *gemini.Request) {
|
||||||
|
log.Printf("request: %s, user: %v", req.URL.Path, strings.Join(userName(req), " "))
|
||||||
switch req.URL.Path {
|
switch req.URL.Path {
|
||||||
case "/":
|
case "/":
|
||||||
gemini.ServeFileName("pages/home.gmi", "text/gemini")(w, req)
|
gemini.ServeFileName("pages/home.gmi", "text/gemini")(w, req)
|
||||||
|
|
@ -90,27 +76,17 @@ func userName(r *gemini.Request) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Set up structured JSON logging
|
println("Starting gemini server")
|
||||||
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
|
var host, cert, key string
|
||||||
flag.StringVar(&host, "host", ":1965", "listen on host and port. Example: hostname:1965")
|
flag.StringVar(&host, "host", ":1965", "listen on host and port. Example: hostname:1965")
|
||||||
flag.StringVar(&cert, "cert", "server.crt.pem", "certificate file")
|
flag.StringVar(&cert, "cert", "server.crt.pem", "certificate file")
|
||||||
flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file")
|
flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
pbClient := pocketbase.NewPocketBaseClient()
|
handler := ExampleHandler{}
|
||||||
handler := MainHandler{
|
|
||||||
blog: microblog.NewHandler(pbClient),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini))
|
err := gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("server failed to start", "error", err)
|
log.Fatal(err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,8 @@ My personal website:
|
||||||
So far I've joined the following communities here in gemspace:
|
So far I've joined the following communities here in gemspace:
|
||||||
=> gemini://station.martinrue.com/travisshears
|
=> gemini://station.martinrue.com/travisshears
|
||||||
|
|
||||||
## Capsule Features
|
|
||||||
|
|
||||||
=> /microblog Microblog - Aggregation of all my microblog posts
|
|
||||||
|
|
||||||
## Site updates
|
## 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 ==
|
== 26.09.2025 ==
|
||||||
|
|
||||||
Initial deployment
|
Initial deployment
|
||||||
|
|
@ -60,5 +48,3 @@ 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:
|
You can find the source code for this capsule here:
|
||||||
=> https://git.travisshears.com/travisshears/personal-gemini-capsule
|
=> https://git.travisshears.com/travisshears/personal-gemini-capsule
|
||||||
Diagram created with:
|
|
||||||
=> https://asciiflow.com
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue