kb/main.go
2025-12-17 09:00:24 -05:00

306 lines
8.1 KiB
Go

// main.go
package main
import (
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"fmt"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
const (
contentDir = "./content"
port = "80"
)
var (
md = goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Table,
extension.Strikethrough,
extension.TaskList,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
),
)
templates = template.Must(template.ParseGlob("web/templates/*.html"))
)
type Page struct {
Title string
Content template.HTML
RawText string
Path string
IsEdit bool
}
func main() {
// Ensure content directory exists
if err := os.MkdirAll(contentDir, 0755); err != nil {
log.Fatalf("Failed to create content directory: %v", err)
}
// Test write permissions
testFile := filepath.Join(contentDir, ".write-test")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
log.Fatalf("Content directory not writable: %v", err)
}
os.Remove(testFile)
http.HandleFunc("/", handleIndex)
http.HandleFunc("/page/", handlePage)
http.HandleFunc("/edit/", handleEdit)
http.HandleFunc("/save", handleSave)
log.Printf("Starting knowledge base server on %s\n", port)
log.Printf("Content directory: %s\n", contentDir)
if err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%s",port), nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
pages, err := listPages()
if err != nil {
log.Printf("Error listing pages: %v", err)
http.Error(w, "Error loading page list", http.StatusInternalServerError)
return
}
if err := templates.ExecuteTemplate(w, "index.html", map[string]interface{}{
"Pages": pages,
}); err != nil {
log.Printf("Error rendering index: %v", err)
// Headers already sent, can't send error response
}
}
func handlePage(w http.ResponseWriter, r *http.Request) {
pagePath := strings.TrimPrefix(r.URL.Path, "/page/")
if pagePath == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
content, err := readPage(pagePath)
if err != nil {
if os.IsNotExist(err) {
// Page doesn't exist, redirect to create it
http.Redirect(w, r, "/edit/"+pagePath, http.StatusSeeOther)
return
}
// Other error (permissions, I/O error, etc.)
log.Printf("Error reading page %s: %v", pagePath, err)
http.Error(w, "Error loading page", http.StatusInternalServerError)
return
}
var htmlContent strings.Builder
if err := md.Convert([]byte(content), &htmlContent); err != nil {
log.Printf("Error converting markdown for %s: %v", pagePath, err)
http.Error(w, "Error rendering page", http.StatusInternalServerError)
return
}
page := Page{
Title: formatTitle(pagePath),
Content: template.HTML(htmlContent.String()),
Path: pagePath,
IsEdit: false,
}
if err := templates.ExecuteTemplate(w, "page.html", page); err != nil {
log.Printf("Error executing template for %s: %v", pagePath, err)
// Can't use http.Error here - headers already sent
// Just log it; user will see partial page
}
}
func handleEdit(w http.ResponseWriter, r *http.Request) {
pagePath := strings.TrimPrefix(r.URL.Path, "/edit/")
if pagePath == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
content, err := readPage(pagePath)
if err != nil {
// New page
content = "# " + formatTitle(pagePath) + "\n\nStart writing your content here..."
}
page := Page{
Title: formatTitle(pagePath),
RawText: content,
Path: pagePath,
IsEdit: true,
}
templates.ExecuteTemplate(w, "edit.html", page)
}
func handleSave(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
log.Printf("Error parsing form: %v", err)
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
pagePath := r.FormValue("path")
content := r.FormValue("content")
if pagePath == "" {
http.Error(w, "Path is required", http.StatusBadRequest)
return
}
// Validate/sanitize path to prevent directory traversal
if strings.Contains(pagePath, "..") {
log.Printf("Attempted directory traversal: %s", pagePath)
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
if err := savePage(pagePath, content); err != nil {
log.Printf("Error saving page %s: %v", pagePath, err)
if os.IsPermission(err) {
http.Error(w, "Permission denied", http.StatusForbidden)
return
}
http.Error(w, "Error saving page", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/page/"+pagePath, http.StatusSeeOther)
}
func listPages() ([]string, error) {
var pages []string
err := filepath.Walk(contentDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
// Log but continue walking - don't fail entire listing for one bad file
log.Printf("Error accessing %s: %v", path, err)
return nil // Return nil to continue walking
}
if info.IsDir() {
return nil
}
if strings.HasSuffix(path, ".md") {
relPath, err := filepath.Rel(contentDir, path)
if err != nil {
log.Printf("Error getting relative path for %s: %v", path, err)
return nil // Continue despite error
}
pagePath := strings.TrimSuffix(relPath, ".md")
pages = append(pages, pagePath)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walking content directory: %w", err)
}
return pages, nil
}
func formatTitle(pagePath string) string {
parts := strings.Split(pagePath, "/")
title := parts[len(parts)-1]
title = strings.ReplaceAll(title, "-", " ")
title = strings.ReplaceAll(title, "_", " ")
return strings.Title(title)
}
func readPage(pagePath string) (string, error) {
filePath := filepath.Join(contentDir, pagePath+".md")
// Security: ensure the path is within contentDir
absPath, err := filepath.Abs(filePath)
if err != nil {
return "", fmt.Errorf("invalid path: %w", err)
}
absContentDir, err := filepath.Abs(contentDir)
if err != nil {
return "", fmt.Errorf("invalid content dir: %w", err)
}
if !strings.HasPrefix(absPath, absContentDir) {
return "", fmt.Errorf("path outside content directory")
}
data, err := os.ReadFile(filePath)
if err != nil {
return "", err // Let caller distinguish NotExist vs other errors
}
return string(data), nil
}
func savePage(pagePath, content string) error {
filePath := filepath.Join(contentDir, pagePath+".md")
// Security check (same as readPage)
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
absContentDir, err := filepath.Abs(contentDir)
if err != nil {
return fmt.Errorf("invalid content dir: %w", err)
}
if !strings.HasPrefix(absPath, absContentDir) {
return fmt.Errorf("path outside content directory")
}
// Create subdirectories if needed
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
return fmt.Errorf("writing file: %w", err)
}
return nil
}