feat: implement WYSIWYG markdown editor with Go backend and React frontend

- Backend: Go HTTP server with Cobra CLI (--data-dir, --port, --host flags)
- CRUD REST API for markdown files with JSON error responses
- File storage in flat directory structure (flat structure, .md files only)
- Comprehensive logrus logging for all operations
- Static file serving for frontend build (./frontend/dist)
- Frontend: React + TypeScript + Tailwind CSS
- Markdown editor with live GFM preview
- File management: list, create, open, save, delete
- Theme system (Dark, Light, System) with persistence
- Responsive design for desktop and mobile
- Backend tests (storage, API handlers) and frontend tests
This commit is contained in:
2026-02-06 16:00:51 -05:00
parent d683c50c34
commit 702281c6cf
26 changed files with 6290 additions and 0 deletions

176
internal/storage/storage.go Normal file
View File

@@ -0,0 +1,176 @@
package storage
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// FileMetadata holds metadata about a markdown file
type FileMetadata struct {
Filename string `json:"filename"`
Title string `json:"title"`
Modified time.Time `json:"modified"`
Size int64 `json:"size"`
}
// FileContent holds both metadata and content
type FileContent struct {
Filename string `json:"filename"`
Title string `json:"title"`
Content string `json:"content"`
Modified time.Time `json:"modified"`
Size int64 `json:"size"`
}
// Storage handles file operations for markdown files
type Storage struct {
dataDir string
}
// NewStorage creates a new storage instance
func NewStorage(dataDir string) *Storage {
return &Storage{dataDir: dataDir}
}
// validateFilename checks if the filename is valid
func (s *Storage) validateFilename(filename string) error {
// Must end with .md
if !strings.HasSuffix(filename, ".md") {
return fmt.Errorf("only .md files are allowed")
}
// Check for path traversal
if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
return fmt.Errorf("invalid filename")
}
// Check for empty filename
if filename == "" {
return fmt.Errorf("filename cannot be empty")
}
return nil
}
// ListFiles returns a list of all markdown files
func (s *Storage) ListFiles() ([]FileMetadata, error) {
files, err := os.ReadDir(s.dataDir)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
metadata := []FileMetadata{}
re := regexp.MustCompile(`^(.+)\.md$`)
for _, file := range files {
if file.IsDir() {
continue
}
if !strings.HasSuffix(file.Name(), ".md") {
continue
}
info, err := file.Info()
if err != nil {
continue
}
name := re.ReplaceAllString(file.Name(), "$1")
metadata = append(metadata, FileMetadata{
Filename: file.Name(),
Title: name,
Modified: info.ModTime(),
Size: info.Size(),
})
}
return metadata, nil
}
// GetFile reads a markdown file by filename
func (s *Storage) GetFile(filename string) (*FileContent, error) {
if err := s.validateFilename(filename); err != nil {
return nil, fmt.Errorf("invalid filename: %w", err)
}
path := filepath.Join(s.dataDir, filename)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("file not found")
}
return nil, fmt.Errorf("failed to read file: %w", err)
}
return &FileContent{
Filename: filename,
Content: string(data),
Title: strings.TrimSuffix(filename, ".md"),
}, nil
}
// CreateFile creates a new markdown file
func (s *Storage) CreateFile(filename string, content string) (*FileContent, error) {
if err := s.validateFilename(filename); err != nil {
return nil, fmt.Errorf("invalid filename: %w", err)
}
path := filepath.Join(s.dataDir, filename)
if _, err := os.Stat(path); !os.IsNotExist(err) {
return nil, fmt.Errorf("file already exists")
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return nil, fmt.Errorf("failed to write file: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
return &FileContent{
Filename: filename,
Content: content,
Title: strings.TrimSuffix(filename, ".md"),
Modified: info.ModTime(),
Size: info.Size(),
}, nil
}
// UpdateFile updates an existing markdown file
func (s *Storage) UpdateFile(filename string, content string) (*FileContent, error) {
if err := s.validateFilename(filename); err != nil {
return nil, fmt.Errorf("invalid filename: %w", err)
}
path := filepath.Join(s.dataDir, filename)
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("file not found")
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return nil, fmt.Errorf("failed to write file: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
return &FileContent{
Filename: filename,
Content: content,
Title: strings.TrimSuffix(filename, ".md"),
Modified: info.ModTime(),
Size: info.Size(),
}, nil
}
// DeleteFile deletes a markdown file
func (s *Storage) DeleteFile(filename string) error {
if err := s.validateFilename(filename); err != nil {
return fmt.Errorf("invalid filename: %w", err)
}
path := filepath.Join(s.dataDir, filename)
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("file not found")
}
return fmt.Errorf("failed to delete file: %w", err)
}
return nil
}
// ValidateFilename is a public wrapper for testing
func (s *Storage) ValidateFilename(filename string) error {
return s.validateFilename(filename)
}

View File

@@ -0,0 +1,237 @@
package storage
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestStorage_Init(t *testing.T) {
// Create temp directory for testing
tempDir := t.TempDir()
_ = NewStorage(tempDir)
// Verify directory exists
if _, err := os.Stat(tempDir); os.IsNotExist(err) {
t.Errorf("data directory should be created")
}
// Verify it's empty
files, err := os.ReadDir(tempDir)
if err != nil {
t.Fatalf("failed to read directory: %v", err)
}
if len(files) != 0 {
t.Errorf("directory should be empty initially")
}
}
func TestStorage_ListFiles_Empty(t *testing.T) {
tempDir := t.TempDir()
store := NewStorage(tempDir)
files, err := store.ListFiles()
if err != nil {
t.Fatalf("ListFiles failed: %v", err)
}
if len(files) != 0 {
t.Errorf("expected 0 files, got %d", len(files))
}
}
func TestStorage_ListFiles_Multiple(t *testing.T) {
tempDir := t.TempDir()
store := NewStorage(tempDir)
// Create test files
testFiles := []string{"file1.md", "file2.md", "file3.md"}
for _, name := range testFiles {
content := "# Test " + name
if err := os.WriteFile(filepath.Join(tempDir, name), []byte(content), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
}
files, err := store.ListFiles()
if err != nil {
t.Fatalf("ListFiles failed: %v", err)
}
if len(files) != 3 {
t.Errorf("expected 3 files, got %d", len(files))
}
}
func TestStorage_CRUD(t *testing.T) {
tempDir := t.TempDir()
store := NewStorage(tempDir)
// Test Create
filename := "test.md"
content := "# Hello World\n\nThis is a test file."
created, err := store.CreateFile(filename, content)
if err != nil {
t.Fatalf("CreateFile failed: %v", err)
}
if created.Filename != filename {
t.Errorf("expected filename %s, got %s", filename, created.Filename)
}
if created.Content != content {
t.Errorf("expected content %s, got %s", content, created.Content)
}
if created.Title != "test" {
t.Errorf("expected title 'test', got '%s'", created.Title)
}
if created.Modified.IsZero() {
t.Errorf("modified timestamp should not be zero")
}
if created.Size != int64(len(content)) {
t.Errorf("expected size %d, got %d", len(content), created.Size)
}
// Test Get
retrieved, err := store.GetFile(filename)
if err != nil {
t.Fatalf("GetFile failed: %v", err)
}
if retrieved.Content != content {
t.Errorf("expected content %s, got %s", content, retrieved.Content)
}
// Test Update
newContent := "# Updated\n\nContent was updated."
updated, err := store.UpdateFile(filename, newContent)
if err != nil {
t.Fatalf("UpdateFile failed: %v", err)
}
if updated.Content != newContent {
t.Errorf("expected updated content %s, got %s", newContent, updated.Content)
}
if updated.Modified.Before(created.Modified) {
t.Errorf("modified timestamp should be updated")
}
// Test List includes updated file
files, err := store.ListFiles()
if err != nil {
t.Fatalf("ListFiles failed: %v", err)
}
var found bool
for _, f := range files {
if f.Filename == filename {
found = true
if f.Size != int64(len(newContent)) {
t.Errorf("expected size %d for list, got %d", len(newContent), f.Size)
}
}
}
if !found {
t.Errorf("updated file not found in list")
}
// Test Delete
if err := store.DeleteFile(filename); err != nil {
t.Fatalf("DeleteFile failed: %v", err)
}
if _, err := os.Stat(filepath.Join(tempDir, filename)); !os.IsNotExist(err) {
t.Errorf("file should be deleted")
}
// Verify file is gone
if _, err := store.GetFile(filename); err == nil {
t.Errorf("GetFile should fail after deletion")
}
}
func TestStorage_ValidateFilename(t *testing.T) {
tempDir := t.TempDir()
store := NewStorage(tempDir)
tests := []struct {
name string
filename string
valid bool
}{
{"valid .md file", "test.md", true},
{"valid with numbers", "file123.md", true},
{"invalid without extension", "test", false},
{"invalid wrong extension", "test.txt", false},
{"invalid path traversal", "../etc/passwd", false},
{"invalid with slash", "dir/file.md", false},
{"empty filename", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := store.ValidateFilename(tt.filename)
if tt.valid && err != nil {
t.Errorf("expected valid, got error: %v", err)
}
if !tt.valid && err == nil {
t.Errorf("expected error, got nil")
}
})
}
}
func TestStorage_FileNotFound(t *testing.T) {
tempDir := t.TempDir()
store := NewStorage(tempDir)
_, err := store.GetFile("nonexistent.md")
if err == nil {
t.Errorf("GetFile should return error for non-existent file")
}
_, err = store.UpdateFile("nonexistent.md", "content")
if err == nil {
t.Errorf("UpdateFile should return error for non-existent file")
}
err = store.DeleteFile("nonexistent.md")
if err == nil {
t.Errorf("DeleteFile should return error for non-existent file")
}
}
func TestStorage_FileExists(t *testing.T) {
tempDir := t.TempDir()
store := NewStorage(tempDir)
// Create file
_, err := store.CreateFile("exists.md", "content")
if err != nil {
t.Fatalf("CreateFile failed: %v", err)
}
// Try to create again
_, err = store.CreateFile("exists.md", "content")
if err == nil {
t.Errorf("CreateFile should fail for existing file")
}
}
func TestStorage_ModificationTime(t *testing.T) {
tempDir := t.TempDir()
store := NewStorage(tempDir)
// Create file
created, err := store.CreateFile("test.md", "content")
if err != nil {
t.Fatalf("CreateFile failed: %v", err)
}
// Wait a moment
time.Sleep(100 * time.Millisecond)
// Update file
updated, err := store.UpdateFile("test.md", "new content")
if err != nil {
t.Fatalf("UpdateFile failed: %v", err)
}
// Verify modification time was updated
if !updated.Modified.After(created.Modified) {
t.Errorf("modified time should be updated on update")
}
}