feat: implement WYSIWYG markdown editor

Add full-stack markdown editor with Go backend and React frontend.

Backend:
- Cobra CLI with --data-dir, --port, --host flags
- REST API for markdown file CRUD operations
- File storage with flat directory structure
- logrus logging for all operations
- Static file serving for frontend
- Comprehensive tests for CRUD and static assets

Frontend:
- React + TypeScript + Vite + Tailwind CSS
- Live markdown preview with marked (GFM)
- File management: list, create, open, save, delete
- Theme system: Dark/Light/System with persistence
- Responsive design (320px to 1920px)
- Component tests for Editor, Preview, Sidebar

Build:
- Makefile for build, test, and run automation
- Single command testing (make test)

Closes SPEC.md requirements
This commit is contained in:
2026-02-06 10:01:09 -05:00
parent 6b66cee21d
commit 773b9db4b2
44 changed files with 7692 additions and 0 deletions

11
backend/go.mod Normal file
View File

@@ -0,0 +1,11 @@
module markdown-editor-backend
go 1.25.5
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.13.0 // indirect
)

14
backend/go.sum Normal file
View File

@@ -0,0 +1,14 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,176 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/sirupsen/logrus"
"markdown-editor-backend/storage"
)
// Handler contains HTTP handlers for the API
type Handler struct {
storage *storage.Storage
log *logrus.Logger
}
// New creates a new Handler instance
func New(storage *storage.Storage, log *logrus.Logger) *Handler {
return &Handler{
storage: storage,
log: log,
}
}
// FileRequest represents a request body for file operations
type FileRequest struct {
Name string `json:"name"`
Content string `json:"content"`
}
// FileResponse represents a file response
type FileResponse struct {
Name string `json:"name"`
Content string `json:"content,omitempty"`
}
// ErrorResponse represents an error response
type ErrorResponse struct {
Error string `json:"error"`
}
// FilesHandler handles list and create operations
func (h *Handler) FilesHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.listFiles(w, r)
case http.MethodPost:
h.createFile(w, r)
default:
h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
// FileHandler handles get, update, and delete operations for a specific file
func (h *Handler) FileHandler(w http.ResponseWriter, r *http.Request) {
// Extract filename from URL path
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
if path == "" {
h.sendError(w, http.StatusBadRequest, "filename required")
return
}
name := strings.TrimSuffix(path, ".md")
switch r.Method {
case http.MethodGet:
h.getFile(w, r, name)
case http.MethodPut:
h.updateFile(w, r, name)
case http.MethodDelete:
h.deleteFile(w, r, name)
default:
h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}
func (h *Handler) listFiles(w http.ResponseWriter, r *http.Request) {
h.log.Debug("Handling list files request")
files, err := h.storage.List()
if err != nil {
h.log.WithError(err).Error("Failed to list files")
h.sendError(w, http.StatusInternalServerError, "failed to list files")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
}
func (h *Handler) createFile(w http.ResponseWriter, r *http.Request) {
h.log.Debug("Handling create file request")
var req FileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.log.WithError(err).Warn("Invalid request body")
h.sendError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
h.sendError(w, http.StatusBadRequest, "name is required")
return
}
if err := h.storage.Save(req.Name, req.Content); err != nil {
h.log.WithError(err).Error("Failed to create file")
h.sendError(w, http.StatusInternalServerError, "failed to create file")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(FileResponse{Name: req.Name, Content: req.Content})
}
func (h *Handler) getFile(w http.ResponseWriter, r *http.Request, name string) {
h.log.WithField("name", name).Debug("Handling get file request")
content, err := h.storage.Get(name)
if err != nil {
if err.Error() == "file not found" {
h.sendError(w, http.StatusNotFound, "file not found")
return
}
h.log.WithError(err).Error("Failed to get file")
h.sendError(w, http.StatusInternalServerError, "failed to get file")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(FileResponse{Name: name, Content: content})
}
func (h *Handler) updateFile(w http.ResponseWriter, r *http.Request, name string) {
h.log.WithField("name", name).Debug("Handling update file request")
var req FileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.log.WithError(err).Warn("Invalid request body")
h.sendError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.storage.Save(name, req.Content); err != nil {
h.log.WithError(err).Error("Failed to update file")
h.sendError(w, http.StatusInternalServerError, "failed to update file")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(FileResponse{Name: name, Content: req.Content})
}
func (h *Handler) deleteFile(w http.ResponseWriter, r *http.Request, name string) {
h.log.WithField("name", name).Debug("Handling delete file request")
if err := h.storage.Delete(name); err != nil {
if err.Error() == "file not found" {
h.sendError(w, http.StatusNotFound, "file not found")
return
}
h.log.WithError(err).Error("Failed to delete file")
h.sendError(w, http.StatusInternalServerError, "failed to delete file")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) sendError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}

53
backend/main.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"fmt"
"os"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"markdown-editor-backend/server"
)
var (
dataDir string
port int
host string
log = logrus.New()
)
var rootCmd = &cobra.Command{
Use: "markdown-editor-backend",
Short: "WYSIWYG Markdown Editor Backend",
Long: `A Go backend server for the WYSIWYG Markdown Editor with CRUD operations for markdown files.`,
Run: func(cmd *cobra.Command, args []string) {
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
})
log.SetLevel(logrus.InfoLevel)
log.WithFields(logrus.Fields{
"dataDir": dataDir,
"host": host,
"port": port,
}).Info("Starting server")
srv := server.New(dataDir, host, port, log)
if err := srv.Start(); err != nil {
log.WithError(err).Fatal("Server failed to start")
}
},
}
func init() {
rootCmd.Flags().StringVar(&dataDir, "data-dir", "./data", "Storage path for markdown files")
rootCmd.Flags().IntVar(&port, "port", 8080, "Server port")
rootCmd.Flags().StringVar(&host, "host", "127.0.0.1", "Bind address")
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

172
backend/server/server.go Normal file
View File

@@ -0,0 +1,172 @@
package server
import (
"encoding/json"
"fmt"
"net"
"net/http"
"github.com/sirupsen/logrus"
"markdown-editor-backend/handlers"
"markdown-editor-backend/storage"
)
// Server represents the HTTP server
type Server struct {
dataDir string
host string
port int
log *logrus.Logger
storage *storage.Storage
handler *handlers.Handler
staticPath string
listener net.Listener
}
// New creates a new Server instance
func New(dataDir, host string, port int, log *logrus.Logger) *Server {
store := storage.New(dataDir, log)
handler := handlers.New(store, log)
return &Server{
dataDir: dataDir,
host: host,
port: port,
log: log,
storage: store,
handler: handler,
staticPath: "./static",
}
}
// SetStaticPath sets the path for static files (used for embedding frontend)
func (s *Server) SetStaticPath(path string) {
s.staticPath = path
}
// Start initializes storage and starts the HTTP server
func (s *Server) Start() error {
if err := s.storage.Init(); err != nil {
return err
}
mux := http.NewServeMux()
// API routes
mux.HandleFunc("/api/files", s.handler.FilesHandler)
mux.HandleFunc("/api/files/", s.handler.FileHandler)
// Health check
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// Static file serving - serve frontend build
fs := http.FileServer(http.Dir(s.staticPath))
mux.Handle("/", fs)
// CORS middleware
handler := s.corsMiddleware(mux)
addr := fmt.Sprintf("%s:%d", s.host, s.port)
s.log.WithField("address", addr).Info("Server starting")
listener, err := net.Listen("tcp", addr)
if err != nil {
return err
}
s.listener = listener
return http.Serve(listener, handler)
}
// StartAsync starts the server asynchronously and returns the actual address
func (s *Server) StartAsync() (string, error) {
if err := s.storage.Init(); err != nil {
return "", err
}
mux := http.NewServeMux()
// API routes
mux.HandleFunc("/api/files", s.handler.FilesHandler)
mux.HandleFunc("/api/files/", s.handler.FileHandler)
// Health check
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// Static file serving - serve frontend build
fs := http.FileServer(http.Dir(s.staticPath))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" {
return
}
fs.ServeHTTP(w, r)
})
// CORS middleware
handler := s.corsMiddleware(mux)
addr := fmt.Sprintf("%s:%d", s.host, s.port)
if s.port == 0 {
addr = fmt.Sprintf("%s:0", s.host)
}
listener, err := net.Listen("tcp", addr)
if err != nil {
return "", err
}
s.listener = listener
actualAddr := listener.Addr().String()
s.log.WithField("address", actualAddr).Info("Server starting")
go http.Serve(listener, handler)
return actualAddr, nil
}
// Stop stops the server
func (s *Server) Stop() error {
if s.listener != nil {
return s.listener.Close()
}
return nil
}
// corsMiddleware adds CORS headers for frontend development
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// GetStorage returns the storage instance (for testing)
func (s *Server) GetStorage() *storage.Storage {
return s.storage
}
// GetStaticPath returns the static files path (for testing)
func (s *Server) GetStaticPath() string {
return s.staticPath
}
// GetAddr returns the server address (for testing)
func (s *Server) GetAddr() string {
if s.listener != nil {
return s.listener.Addr().String()
}
return fmt.Sprintf("%s:%d", s.host, s.port)
}

View File

@@ -0,0 +1,255 @@
package server_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/sirupsen/logrus"
"markdown-editor-backend/server"
)
func TestServerCRUD(t *testing.T) {
// Create temp directory for test data
tempDir, err := os.MkdirTemp("", "markdown-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create static directory
staticDir := filepath.Join(tempDir, "static")
if err := os.MkdirAll(staticDir, 0755); err != nil {
t.Fatalf("Failed to create static dir: %v", err)
}
if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte("<html></html>"), 0644); err != nil {
t.Fatalf("Failed to create index.html: %v", err)
}
// Create logger
log := logrus.New()
log.SetLevel(logrus.WarnLevel)
// Create server with dynamic port
srv := server.New(tempDir, "127.0.0.1", 0, log)
srv.SetStaticPath(staticDir)
// Start server asynchronously
addr, err := srv.StartAsync()
if err != nil {
t.Fatalf("Failed to start server: %v", err)
}
defer srv.Stop()
// Wait for server to be ready
time.Sleep(100 * time.Millisecond)
baseURL := "http://" + addr
client := &http.Client{Timeout: 5 * time.Second}
// Test health endpoint
t.Run("HealthCheck", func(t *testing.T) {
resp, err := client.Get(baseURL + "/api/health")
if err != nil {
t.Fatalf("Health check failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if result["status"] != "ok" {
t.Errorf("Expected status 'ok', got '%s'", result["status"])
}
})
// Test Create
t.Run("CreateFile", func(t *testing.T) {
body := map[string]string{
"name": "test-file",
"content": "# Hello World\n\nThis is a test file.",
}
jsonBody, _ := json.Marshal(body)
resp, err := client.Post(baseURL+"/api/files", "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
t.Fatalf("Create file failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("Expected status 201, got %d", resp.StatusCode)
}
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if result["name"] != "test-file" {
t.Errorf("Expected name 'test-file', got '%s'", result["name"])
}
})
// Test List
t.Run("ListFiles", func(t *testing.T) {
resp, err := client.Get(baseURL + "/api/files")
if err != nil {
t.Fatalf("List files failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
var result []string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
found := false
for _, name := range result {
if name == "test-file" {
found = true
break
}
}
if !found {
t.Errorf("Expected 'test-file' in list, got %v", result)
}
})
// Test Get
t.Run("GetFile", func(t *testing.T) {
resp, err := client.Get(baseURL + "/api/files/test-file")
if err != nil {
t.Fatalf("Get file failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if result["name"] != "test-file" {
t.Errorf("Expected name 'test-file', got '%s'", result["name"])
}
if result["content"] != "# Hello World\n\nThis is a test file." {
t.Errorf("Expected specific content, got '%s'", result["content"])
}
})
// Test Update
t.Run("UpdateFile", func(t *testing.T) {
body := map[string]string{
"content": "# Updated Content\n\nThis file has been updated.",
}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPut, baseURL+"/api/files/test-file", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Update file failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
// Verify the update
resp2, _ := client.Get(baseURL + "/api/files/test-file")
defer resp2.Body.Close()
var result map[string]string
json.NewDecoder(resp2.Body).Decode(&result)
if result["content"] != "# Updated Content\n\nThis file has been updated." {
t.Errorf("Expected updated content, got '%s'", result["content"])
}
})
// Test Delete
t.Run("DeleteFile", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodDelete, baseURL+"/api/files/test-file", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Delete file failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("Expected status 204, got %d", resp.StatusCode)
}
// Verify deletion
resp2, _ := client.Get(baseURL + "/api/files/test-file")
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusNotFound {
t.Errorf("Expected status 404 for deleted file, got %d", resp2.StatusCode)
}
})
// Test 404
t.Run("GetNonExistentFile", func(t *testing.T) {
resp, err := client.Get(baseURL + "/api/files/nonexistent")
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("Expected status 404, got %d", resp.StatusCode)
}
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if result["error"] != "file not found" {
t.Errorf("Expected error 'file not found', got '%s'", result["error"])
}
})
// Test static file serving
t.Run("StaticFiles", func(t *testing.T) {
resp, err := client.Get(baseURL + "/")
if err != nil {
t.Fatalf("Get static file failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "<html></html>" {
t.Errorf("Expected '<html></html>', got '%s'", string(body))
}
})
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
backend/static/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markdown Editor</title>
<script type="module" crossorigin src="/assets/index-BRD_q2gl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CevFFMbj.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

1
backend/static/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

150
backend/storage/storage.go Normal file
View File

@@ -0,0 +1,150 @@
package storage
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/sirupsen/logrus"
)
// Storage handles file operations for markdown files
type Storage struct {
dataDir string
mu sync.RWMutex
log *logrus.Logger
}
// New creates a new Storage instance
func New(dataDir string, log *logrus.Logger) *Storage {
return &Storage{
dataDir: dataDir,
log: log,
}
}
// Init ensures the data directory exists
func (s *Storage) Init() error {
s.mu.Lock()
defer s.mu.Unlock()
s.log.WithField("dataDir", s.dataDir).Info("Initializing storage")
if err := os.MkdirAll(s.dataDir, 0755); err != nil {
s.log.WithError(err).Error("Failed to create data directory")
return fmt.Errorf("failed to create data directory: %w", err)
}
return nil
}
// List returns all markdown files in the data directory
func (s *Storage) List() ([]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
s.log.Debug("Listing all files")
entries, err := os.ReadDir(s.dataDir)
if err != nil {
s.log.WithError(err).Error("Failed to read data directory")
return nil, fmt.Errorf("failed to read data directory: %w", err)
}
var files []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
files = append(files, strings.TrimSuffix(entry.Name(), ".md"))
}
}
s.log.WithField("count", len(files)).Debug("Listed files")
return files, nil
}
// Get reads a markdown file by name
func (s *Storage) Get(name string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
s.log.WithField("name", name).Debug("Getting file")
if !isValidFilename(name) {
s.log.WithField("name", name).Warn("Invalid filename")
return "", fmt.Errorf("invalid filename")
}
path := filepath.Join(s.dataDir, name+".md")
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
s.log.WithField("name", name).Warn("File not found")
return "", fmt.Errorf("file not found")
}
s.log.WithError(err).WithField("name", name).Error("Failed to read file")
return "", fmt.Errorf("failed to read file: %w", err)
}
return string(content), nil
}
// Save writes content to a markdown file
func (s *Storage) Save(name, content string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.log.WithField("name", name).Debug("Saving file")
if !isValidFilename(name) {
s.log.WithField("name", name).Warn("Invalid filename")
return fmt.Errorf("invalid filename")
}
path := filepath.Join(s.dataDir, name+".md")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
s.log.WithError(err).WithField("name", name).Error("Failed to write file")
return fmt.Errorf("failed to write file: %w", err)
}
s.log.WithField("name", name).Info("File saved")
return nil
}
// Delete removes a markdown file
func (s *Storage) Delete(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.log.WithField("name", name).Debug("Deleting file")
if !isValidFilename(name) {
s.log.WithField("name", name).Warn("Invalid filename")
return fmt.Errorf("invalid filename")
}
path := filepath.Join(s.dataDir, name+".md")
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
s.log.WithField("name", name).Warn("File not found for deletion")
return fmt.Errorf("file not found")
}
s.log.WithError(err).WithField("name", name).Error("Failed to delete file")
return fmt.Errorf("failed to delete file: %w", err)
}
s.log.WithField("name", name).Info("File deleted")
return nil
}
// isValidFilename checks if a filename is safe to use
func isValidFilename(name string) bool {
if name == "" {
return false
}
// Prevent path traversal
if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
return false
}
return true
}