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:
253
internal/api/handlers.go
Normal file
253
internal/api/handlers.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"eval/internal/config"
|
||||
"eval/internal/storage"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Handlers holds the API handlers
|
||||
type Handlers struct {
|
||||
store *storage.Storage
|
||||
config *config.Config
|
||||
logger *logrus.Logger
|
||||
}
|
||||
|
||||
// NewHandlers creates a new Handlers instance
|
||||
func NewHandlers(store *storage.Storage, config *config.Config, logger *logrus.Logger) *Handlers {
|
||||
return &Handlers{
|
||||
store: store,
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error response
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// writeJSON writes a JSON response
|
||||
func (h *Handlers) writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
h.logger.Errorf("failed to encode response: %v", err)
|
||||
http.Error(w, `{"error": "internal server error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// writeError writes an error response
|
||||
func (h *Handlers) writeError(w http.ResponseWriter, status int, message string) {
|
||||
h.writeJSON(w, status, ErrorResponse{Error: message})
|
||||
}
|
||||
|
||||
// listFilesHandler handles GET /api/files - list all markdown files
|
||||
func (h *Handlers) listFilesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("listing files")
|
||||
files, err := h.store.ListFiles()
|
||||
if err != nil {
|
||||
h.logger.Errorf("failed to list files: %v", err)
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to list files")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, files)
|
||||
}
|
||||
|
||||
// getFileHandler handles GET /api/files/{filename} - get a specific file
|
||||
func (h *Handlers) getFileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
|
||||
filename := path
|
||||
if filename == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
h.logger.WithField("filename", filename).Info("getting file")
|
||||
file, err := h.store.GetFile(filename)
|
||||
if err != nil {
|
||||
if err.Error() == "file not found" {
|
||||
h.writeError(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
h.logger.Errorf("failed to get file: %v", err)
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to get file")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, file)
|
||||
}
|
||||
|
||||
// createFileHandler handles POST /api/files - create a new file
|
||||
func (h *Handlers) createFileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var file storage.FileContent
|
||||
if err := json.NewDecoder(r.Body).Decode(&file); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if file.Filename == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
h.logger.WithField("filename", file.Filename).Info("creating file")
|
||||
result, err := h.store.CreateFile(file.Filename, file.Content)
|
||||
if err != nil {
|
||||
if err.Error() == "file already exists" {
|
||||
h.writeError(w, http.StatusConflict, "file already exists")
|
||||
return
|
||||
}
|
||||
h.logger.Errorf("failed to create file: %v", err)
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to create file")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// updateFileHandler handles PUT /api/files/{filename} - update a file
|
||||
func (h *Handlers) updateFileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
|
||||
filename := path
|
||||
if filename == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
var file storage.FileContent
|
||||
if err := json.NewDecoder(r.Body).Decode(&file); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
h.logger.WithField("filename", filename).Info("updating file")
|
||||
result, err := h.store.UpdateFile(filename, file.Content)
|
||||
if err != nil {
|
||||
if err.Error() == "file not found" {
|
||||
h.writeError(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
h.logger.Errorf("failed to update file: %v", err)
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to update file")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// deleteFileHandler handles DELETE /api/files/{filename} - delete a file
|
||||
func (h *Handlers) deleteFileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/files/")
|
||||
filename := path
|
||||
if filename == "" {
|
||||
h.writeError(w, http.StatusBadRequest, "filename is required")
|
||||
return
|
||||
}
|
||||
h.logger.WithField("filename", filename).Info("deleting file")
|
||||
if err := h.store.DeleteFile(filename); err != nil {
|
||||
if err.Error() == "file not found" {
|
||||
h.writeError(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
h.logger.Errorf("failed to delete file: %v", err)
|
||||
h.writeError(w, http.StatusInternalServerError, "failed to delete file")
|
||||
return
|
||||
}
|
||||
h.writeJSON(w, http.StatusOK, map[string]string{"message": "file deleted"})
|
||||
}
|
||||
|
||||
// registerAPIRoutes registers all API routes
|
||||
func (h *Handlers) registerAPIRoutes(router *http.ServeMux) {
|
||||
router.HandleFunc("GET /api/files", h.listFilesHandler)
|
||||
router.HandleFunc("GET /api/files/", h.getFileHandler)
|
||||
router.HandleFunc("POST /api/files", h.createFileHandler)
|
||||
router.HandleFunc("PUT /api/files/", h.updateFileHandler)
|
||||
router.HandleFunc("DELETE /api/files/", h.deleteFileHandler)
|
||||
}
|
||||
|
||||
// ServeStaticHandler serves static files
|
||||
type ServeStaticHandler struct {
|
||||
fs http.FileSystem
|
||||
}
|
||||
|
||||
// NewServeStaticHandler creates a new static file handler
|
||||
func NewServeStaticHandler(dir string) (*ServeStaticHandler, error) {
|
||||
fs := http.Dir(dir)
|
||||
_, err := fs.Open(".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("static directory not found: %w", err)
|
||||
}
|
||||
return &ServeStaticHandler{fs: fs}, nil
|
||||
}
|
||||
|
||||
// ServeHTTP serves files from the static directory
|
||||
func (h *ServeStaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
file, err := h.fs.Open(r.URL.Path)
|
||||
if err != nil {
|
||||
// If file not found, serve index.html for SPA routing
|
||||
file, err = h.fs.Open("/index.html")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file info to determine content type
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
||||
}
|
||||
|
||||
// SetupStaticHandler sets up the static file handler
|
||||
func (h *Handlers) SetupStaticHandler(buildDir string) (http.HandlerFunc, error) {
|
||||
handler, err := NewServeStaticHandler(buildDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler.ServeHTTP, nil
|
||||
}
|
||||
|
||||
// SetupRoutes sets up all routes and returns the router
|
||||
func (h *Handlers) SetupRoutes(buildDir string) (*http.ServeMux, error) {
|
||||
router := http.NewServeMux()
|
||||
h.registerAPIRoutes(router)
|
||||
|
||||
// Setup static handler
|
||||
if _, err := os.Stat(buildDir); err == nil {
|
||||
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
http.ServeFile(w, r, filepath.Join(buildDir, "index.html"))
|
||||
return
|
||||
}
|
||||
// Try to serve the file, if not found, serve index.html
|
||||
file, err := http.Dir(buildDir).Open(r.URL.Path)
|
||||
if err != nil {
|
||||
http.ServeFile(w, r, filepath.Join(buildDir, "index.html"))
|
||||
return
|
||||
}
|
||||
file.Close()
|
||||
http.ServeFile(w, r, filepath.Join(buildDir, r.URL.Path))
|
||||
})
|
||||
} else {
|
||||
h.logger.Warnf("build directory not found: %s, static file serving disabled", buildDir)
|
||||
}
|
||||
|
||||
return router, nil
|
||||
}
|
||||
|
||||
// StartServer starts the HTTP server
|
||||
func (h *Handlers) StartServer(router *http.ServeMux) error {
|
||||
addr := fmt.Sprintf("%s:%d", h.config.Host, h.config.Port)
|
||||
h.logger.Infof("starting server on %s", addr)
|
||||
return http.ListenAndServe(addr, router)
|
||||
}
|
||||
380
internal/api/handlers_test.go
Normal file
380
internal/api/handlers_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"eval/internal/config"
|
||||
"eval/internal/storage"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func setupTestEnvironment(t *testing.T) (*Handlers, string) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
cfg := config.NewConfig()
|
||||
cfg.DataDir = tempDir
|
||||
|
||||
logger := logrus.New()
|
||||
logger.SetLevel(logrus.FatalLevel)
|
||||
|
||||
store := storage.NewStorage(tempDir)
|
||||
handlers := NewHandlers(store, cfg, logger)
|
||||
return handlers, tempDir
|
||||
}
|
||||
|
||||
func TestHandlers_ListFiles_Empty(t *testing.T) {
|
||||
handlers, _ := setupTestEnvironment(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/files", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.listFilesHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var files []storage.FileMetadata
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &files); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 0 {
|
||||
t.Errorf("expected 0 files, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers_ListFiles_Multiple(t *testing.T) {
|
||||
handlers, tempDir := setupTestEnvironment(t)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/files", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.listFilesHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var files []storage.FileMetadata
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &files); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 3 {
|
||||
t.Errorf("expected 3 files, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers_GetFile(t *testing.T) {
|
||||
handlers, tempDir := setupTestEnvironment(t)
|
||||
|
||||
// Create test file
|
||||
filename := "test.md"
|
||||
content := "# Hello World\n\nThis is a test file."
|
||||
if err := os.WriteFile(filepath.Join(tempDir, filename), []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/files/"+filename, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.getFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var file storage.FileContent
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &file); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if file.Content != content {
|
||||
t.Errorf("expected content %s, got %s", content, file.Content)
|
||||
}
|
||||
if file.Filename != filename {
|
||||
t.Errorf("expected filename %s, got %s", filename, file.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers_GetFile_NotFound(t *testing.T) {
|
||||
handlers, _ := setupTestEnvironment(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/files/nonexistent.md", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.getFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &errResp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers_CreateFile(t *testing.T) {
|
||||
handlers, _ := setupTestEnvironment(t)
|
||||
|
||||
filename := "newfile.md"
|
||||
content := "# New File\n\nCreated via API."
|
||||
body := &storage.FileContent{
|
||||
Filename: filename,
|
||||
Content: content,
|
||||
Title: "New File",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/files", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.createFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
var file storage.FileContent
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &file); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if file.Content != content {
|
||||
t.Errorf("expected content %s, got %s", content, file.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers_CreateFile_Validation(t *testing.T) {
|
||||
handlers, _ := setupTestEnvironment(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
expected int
|
||||
}{
|
||||
{"empty filename", "", http.StatusBadRequest},
|
||||
{"missing extension", "noext", http.StatusInternalServerError},
|
||||
{"invalid extension", "file.txt", http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := &storage.FileContent{
|
||||
Filename: tt.filename,
|
||||
Content: "content",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/files", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.createFileHandler(w, req)
|
||||
|
||||
if w.Code != tt.expected {
|
||||
t.Errorf("expected status %d, got %d", tt.expected, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers_UpdateFile(t *testing.T) {
|
||||
handlers, tempDir := setupTestEnvironment(t)
|
||||
|
||||
// Create initial file
|
||||
filename := "update.md"
|
||||
content := "# Original"
|
||||
if err := os.WriteFile(filepath.Join(tempDir, filename), []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Update file
|
||||
newContent := "# Updated\n\nContent changed."
|
||||
body := &storage.FileContent{
|
||||
Filename: filename,
|
||||
Content: newContent,
|
||||
Title: "Updated",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/files/"+filename, bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.updateFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var file storage.FileContent
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &file); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
if file.Content != newContent {
|
||||
t.Errorf("expected content %s, got %s", newContent, file.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers_UpdateFile_NotFound(t *testing.T) {
|
||||
handlers, _ := setupTestEnvironment(t)
|
||||
|
||||
body := &storage.FileContent{
|
||||
Filename: "nonexistent.md",
|
||||
Content: "content",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest("PUT", "/api/files/nonexistent.md", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.updateFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers_DeleteFile(t *testing.T) {
|
||||
handlers, tempDir := setupTestEnvironment(t)
|
||||
|
||||
// Create file
|
||||
filename := "delete.md"
|
||||
content := "# To be deleted"
|
||||
if err := os.WriteFile(filepath.Join(tempDir, filename), []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Delete file
|
||||
req := httptest.NewRequest("DELETE", "/api/files/"+filename, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.deleteFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify file is deleted
|
||||
if _, err := os.Stat(filepath.Join(tempDir, filename)); !os.IsNotExist(err) {
|
||||
t.Errorf("file should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers_DeleteFile_NotFound(t *testing.T) {
|
||||
handlers, _ := setupTestEnvironment(t)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/api/files/nonexistent.md", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.deleteFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlers_CRUDRoundTrip(t *testing.T) {
|
||||
handlers, tempDir := setupTestEnvironment(t)
|
||||
|
||||
// Create
|
||||
filename := "roundtrip.md"
|
||||
content := "# Round Trip Test"
|
||||
body := &storage.FileContent{
|
||||
Filename: filename,
|
||||
Content: content,
|
||||
Title: "Round Trip Test",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/files", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handlers.createFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Create failed with status %d", w.Code)
|
||||
}
|
||||
|
||||
var created storage.FileContent
|
||||
json.Unmarshal(w.Body.Bytes(), &created)
|
||||
|
||||
// Read
|
||||
req = httptest.NewRequest("GET", "/api/files/"+filename, nil)
|
||||
w = httptest.NewRecorder()
|
||||
handlers.getFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Read failed with status %d", w.Code)
|
||||
}
|
||||
|
||||
var retrieved storage.FileContent
|
||||
json.Unmarshal(w.Body.Bytes(), &retrieved)
|
||||
|
||||
if retrieved.Content != content {
|
||||
t.Errorf("Content mismatch after read: expected %s, got %s", content, retrieved.Content)
|
||||
}
|
||||
|
||||
// Update
|
||||
newContent := "# Round Trip Updated"
|
||||
body = &storage.FileContent{
|
||||
Filename: filename,
|
||||
Content: newContent,
|
||||
Title: "Round Trip Updated",
|
||||
}
|
||||
jsonBody, _ = json.Marshal(body)
|
||||
|
||||
req = httptest.NewRequest("PUT", "/api/files/"+filename, bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
handlers.updateFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Update failed with status %d", w.Code)
|
||||
}
|
||||
|
||||
// Delete
|
||||
req = httptest.NewRequest("DELETE", "/api/files/"+filename, nil)
|
||||
w = httptest.NewRecorder()
|
||||
handlers.deleteFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Delete failed with status %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify deletion
|
||||
req = httptest.NewRequest("GET", "/api/files/"+filename, nil)
|
||||
w = httptest.NewRecorder()
|
||||
handlers.getFileHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected 404 after delete, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify file is deleted on disk
|
||||
if _, err := os.Stat(filepath.Join(tempDir, filename)); !os.IsNotExist(err) {
|
||||
t.Errorf("file should be deleted on disk")
|
||||
}
|
||||
}
|
||||
54
internal/config/config.go
Normal file
54
internal/config/config.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Config holds the application configuration
|
||||
type Config struct {
|
||||
DataDir string
|
||||
Port int
|
||||
Host string
|
||||
}
|
||||
|
||||
// NewConfig creates a new config with default values
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
DataDir: "./data",
|
||||
Port: 8080,
|
||||
Host: "127.0.0.1",
|
||||
}
|
||||
}
|
||||
|
||||
// AddFlags adds the CLI flags to the command
|
||||
func (c *Config) AddFlags(cmd *cobra.Command) {
|
||||
cmd.PersistentFlags().StringVar(&c.DataDir, "data-dir", c.DataDir, "Storage path for markdown files")
|
||||
cmd.PersistentFlags().IntVar(&c.Port, "port", c.Port, "Server port")
|
||||
cmd.PersistentFlags().StringVar(&c.Host, "host", c.Host, "Bind address")
|
||||
}
|
||||
|
||||
// Validate validates the configuration
|
||||
func (c *Config) Validate() error {
|
||||
if c.Port < 1 || c.Port > 65535 {
|
||||
return fmt.Errorf("invalid port: %d", c.Port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DataDirPath returns the absolute path to the data directory
|
||||
func (c *Config) DataDirPath() string {
|
||||
absPath, err := filepath.Abs(c.DataDir)
|
||||
if err != nil {
|
||||
return c.DataDir
|
||||
}
|
||||
return absPath
|
||||
}
|
||||
|
||||
// EnsureDataDir ensures the data directory exists
|
||||
func (c *Config) EnsureDataDir() error {
|
||||
return os.MkdirAll(c.DataDirPath(), 0755)
|
||||
}
|
||||
176
internal/storage/storage.go
Normal file
176
internal/storage/storage.go
Normal 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)
|
||||
}
|
||||
237
internal/storage/storage_test.go
Normal file
237
internal/storage/storage_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user