diff --git a/.gitignore b/.gitignore index 81bbbae..a82f26a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ keys/ +gemini-server diff --git a/internal/microblog/handler.go b/internal/microblog/handler.go new file mode 100644 index 0000000..07ceee0 --- /dev/null +++ b/internal/microblog/handler.go @@ -0,0 +1,15 @@ +package microblog + +import ( + 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() Handler { + return NewMicroBlog() +} diff --git a/internal/microblog/microblog.go b/internal/microblog/microblog.go new file mode 100644 index 0000000..b74c15a --- /dev/null +++ b/internal/microblog/microblog.go @@ -0,0 +1,208 @@ +package microblog + +import ( + "fmt" + "sort" + "strings" + "time" + + gemini "github.com/kulak/gemini" +) + +// Post represents a single blog post +type Post struct { + ID string + Title string + Content string + Author string + Timestamp time.Time +} + +// MicroBlog manages blog posts +type MicroBlog struct { + posts []Post +} + +// NewMicroBlog creates a new microblog instance +func NewMicroBlog() *MicroBlog { + mb := &MicroBlog{ + posts: make([]Post, 0), + } + + // 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 +} + +// GetRecentPosts returns the most recent posts +func (mb *MicroBlog) GetRecentPosts(limit int) []Post { + // Sort posts by timestamp (newest first) + sortedPosts := make([]Post, len(mb.posts)) + copy(sortedPosts, mb.posts) + + sort.Slice(sortedPosts, func(i, j int) bool { + return sortedPosts[i].Timestamp.After(sortedPosts[j].Timestamp) + }) + + if limit > 0 && len(sortedPosts) > limit { + return sortedPosts[:limit] + } + + return sortedPosts +} + +// HandleBlogRequest handles Gemini requests for the microblog +func (mb *MicroBlog) HandleBlogRequest(w gemini.ResponseWriter, req *gemini.Request) { + path := req.URL.Path + + switch { + case path == "/blog" || path == "/blog/": + mb.serveBlogIndex(w, req) + case strings.HasPrefix(path, "/blog/post/"): + postID := strings.TrimPrefix(path, "/blog/post/") + mb.servePost(w, req, postID) + case path == "/blog/new": + mb.serveNewPostForm(w, req) + default: + w.WriteStatusMsg(gemini.StatusNotFound, "Blog page not found") + } +} + +// serveBlogIndex serves the main blog page with recent posts +func (mb *MicroBlog) serveBlogIndex(w gemini.ResponseWriter, req *gemini.Request) { + w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + + var content strings.Builder + content.WriteString("# Gemini Microblog\n\n") + + posts := mb.GetRecentPosts(10) + + if len(posts) == 0 { + content.WriteString("No posts yet. Be the first to write something!\n\n") + } else { + content.WriteString("## Recent Posts\n\n") + + for _, post := range posts { + content.WriteString(fmt.Sprintf("=> /blog/post/%s %s\n", post.ID, post.Title)) + content.WriteString(fmt.Sprintf(" By %s on %s\n\n", + post.Author, + post.Timestamp.Format("2006-01-02 15:04"))) + } + } + + content.WriteString("## Actions\n\n") + content.WriteString("=> /blog/new Write a new post\n") + 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())) +} + +// serveNewPostForm serves the form for creating a new post +func (mb *MicroBlog) serveNewPostForm(w gemini.ResponseWriter, req *gemini.Request) { + // Check if user is authenticated + if req.Certificate() == nil { + w.WriteStatusMsg(gemini.StatusCertRequired, "Authentication required to create posts") + return + } + + w.WriteStatusMsg(gemini.StatusSuccess, "text/gemini") + + var content strings.Builder + content.WriteString("# Write a New Post\n\n") + content.WriteString("To create a new post, use the Titan protocol:\n\n") + content.WriteString("titan://your-server/blog/create;mime=text/plain;token=your-auth-token\n\n") + content.WriteString("Format your post as:\n") + content.WriteString("Title: Your Post Title\n") + content.WriteString("Content: Your post content goes here...\n\n") + content.WriteString("=> /blog Back to blog\n") + + w.WriteBody([]byte(content.String())) +} + +// parsePostData parses the post data from Titan payload +func (mb *MicroBlog) parsePostData(data string) (title, content string) { + lines := strings.Split(data, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Title:") { + title = strings.TrimSpace(strings.TrimPrefix(line, "Title:")) + } else if strings.HasPrefix(line, "Content:") { + content = strings.TrimSpace(strings.TrimPrefix(line, "Content:")) + } + } + + return title, content +} diff --git a/main.go b/main.go index 69ece37..bfd7582 100644 --- a/main.go +++ b/main.go @@ -10,16 +10,26 @@ import ( "strings" "time" + "gemini_site/internal/microblog" + gemini "github.com/kulak/gemini" ) type ExampleHandler struct { + blog microblog.Handler } func (h ExampleHandler) 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, "/blog") { + h.blog.HandleBlogRequest(w, req) + return + } + switch req.URL.Path { case "/": gemini.ServeFileName("pages/home.gmi", "text/gemini")(w, req) @@ -92,7 +102,9 @@ func main() { flag.StringVar(&key, "key", "server.key.pem", "private key associated with certificate file") flag.Parse() - handler := ExampleHandler{} + handler := ExampleHandler{ + blog: microblog.NewHandler(), + } err := gemini.ListenAndServe(host, cert, key, gemini.TrapPanic(handler.ServeGemini)) if err != nil { diff --git a/pages/home.gmi b/pages/home.gmi index 7ac5864..d3909b0 100644 --- a/pages/home.gmi +++ b/pages/home.gmi @@ -18,6 +18,10 @@ My personal website: So far I've joined the following communities here in gemspace: => gemini://station.martinrue.com/travisshears +## Features + +=> /blog Microblog - Read and write posts + ## Site updates == 26.09.2025 ==