- 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
176 lines
4.7 KiB
Go
176 lines
4.7 KiB
Go
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)
|
|
} |