// 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 }