From cc78733a45af89a7073dfd4fa68f5dae2abb149a Mon Sep 17 00:00:00 2001 From: Travis Shears Date: Tue, 30 Sep 2025 09:41:38 +0200 Subject: [PATCH] init repo from gemini capsule project --- README.md | 5 + dev.sh | 7 + go.mod | 7 + internal/microblog/handler.go | 17 ++ internal/microblog/microblog.go | 283 +++++++++++++++++++++++ internal/microblog/pocketbase.go | 227 ++++++++++++++++++ internal/microblog/pocketbase_example.go | 19 ++ internal/pocketbase/pb.go | 197 ++++++++++++++++ main.go | 116 ++++++++++ test_pocketbase.go | 23 ++ 10 files changed, 901 insertions(+) create mode 100644 README.md create mode 100755 dev.sh create mode 100644 go.mod create mode 100644 internal/microblog/handler.go create mode 100644 internal/microblog/microblog.go create mode 100644 internal/microblog/pocketbase.go create mode 100644 internal/microblog/pocketbase_example.go create mode 100644 internal/pocketbase/pb.go create mode 100644 main.go create mode 100644 test_pocketbase.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e1d470 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Gemlog CLI Tool + +Quick tool to write gemtext posts for my gemlog. + +=> gemini://travisshears.com/gemlog diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..a36cc39 --- /dev/null +++ b/dev.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -e + +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 new file mode 100644 index 0000000..a5895a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module gemini_site + +go 1.25.0 + +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 new file mode 100644 index 0000000..4a93954 --- /dev/null +++ b/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "log/slog" + "os" + "strconv" + "strings" + "time" + + "gemini_site/internal/microblog" + "gemini_site/internal/pocketbase" + + gemini "github.com/kulak/gemini" +) + +type MainHandler 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 + } + + switch req.URL.Path { + case "/": + gemini.ServeFileName("pages/home.gmi", "text/gemini")(w, req) + // err := w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + // requireNoError(err) + // _, err = w.WriteBody([]byte("Hello, world!")) + // requireNoError(err) + case "/user": + if req.Certificate() == nil { + w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication Required") + return + } + w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + w.WriteBody([]byte(req.Certificate().Subject.CommonName)) + case "/die": + requireNoError(errors.New("must die")) + case "/file": + gemini.ServeFileName("cmd/example/hello.gmi", "text/gemini")(w, req) + case "/post": + if req.URL.Scheme != gemini.SchemaTitan { + w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + w.WriteBody([]byte("Use titan scheme to upload data")) + return + } + payload, err := req.ReadTitanPayload() + requireNoError(err) + w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + w.WriteBody([]byte("Titan Upload Parameters\r\n")) + w.WriteBody([]byte(fmt.Sprintf("Upload MIME Type: %s\r\n", req.Titan.Mime))) + w.WriteBody([]byte(fmt.Sprintf("Token: %s\r\n", req.Titan.Token))) + w.WriteBody([]byte(fmt.Sprintf("Size: %v\r\n", req.Titan.Size))) + w.WriteBody([]byte("Payload:\r\n")) + w.WriteBody(payload) + + default: + w.WriteStatusMsg(gemini.StatusNotFound, req.URL.Path) + } + +} + +func requireNoError(err error) { + if err != nil { + panic(err) + } +} + +func dateToStr(t time.Time) string { + return strconv.FormatInt(t.Unix(), 36) +} + +func userName(r *gemini.Request) []string { + cert := r.Certificate() + if cert == nil { + return []string{""} + } + return []string{cert.Subject.CommonName, cert.SerialNumber.String(), dateToStr(cert.NotBefore), dateToStr(cert.NotAfter)} +} + +func main() { + // 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() + + pbClient := pocketbase.NewPocketBaseClient() + handler := MainHandler{ + blog: microblog.NewHandler(pbClient), + } + + err := gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini)) + if err != nil { + slog.Error("server failed to start", "error", err) + os.Exit(1) + } +} 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) + } +}