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:
11
backend/go.mod
Normal file
11
backend/go.mod
Normal 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
14
backend/go.sum
Normal 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=
|
||||
176
backend/handlers/handlers.go
Normal file
176
backend/handlers/handlers.go
Normal 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
53
backend/main.go
Normal 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
172
backend/server/server.go
Normal 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)
|
||||
}
|
||||
255
backend/server/server_test.go
Normal file
255
backend/server/server_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
78
backend/static/assets/index-BRD_q2gl.js
Normal file
78
backend/static/assets/index-BRD_q2gl.js
Normal file
File diff suppressed because one or more lines are too long
1
backend/static/assets/index-CevFFMbj.css
Normal file
1
backend/static/assets/index-CevFFMbj.css
Normal file
File diff suppressed because one or more lines are too long
14
backend/static/index.html
Normal file
14
backend/static/index.html
Normal 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
1
backend/static/vite.svg
Normal 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
150
backend/storage/storage.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user