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
151 lines
3.7 KiB
Go
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
|
|
}
|