306 lines
8.1 KiB
Go
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
|
|
}
|