Files
agent-evals/backend/storage/storage.go
Evan Reichard 773b9db4b2 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
2026-02-06 10:01:09 -05:00

151 lines
3.7 KiB
Go

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
}