diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..c620de8 --- /dev/null +++ b/server/README.md @@ -0,0 +1,15 @@ +## Marka Server + + +```json +{ + "name": "Recipe", + "modTime": 123123123123, + "content": [ + { + "name": "Baguette", + "modTime": 123123123123, + } + ] +} +``` diff --git a/server/cmd/marka-server/main.go b/server/cmd/marka-server/main.go index 9bce64c..a6a7c75 100644 --- a/server/cmd/marka-server/main.go +++ b/server/cmd/marka-server/main.go @@ -8,8 +8,8 @@ import ( "path/filepath" "strings" - "git.max-richter.dev/max/marka/server-new/internal/adapters" - "git.max-richter.dev/max/marka/server-new/internal/handler" + "git.max-richter.dev/max/marka/server/internal/adapters" + "git.max-richter.dev/max/marka/server/internal/handler" ) type multi []string diff --git a/server/go.mod b/server/go.mod index f4964eb..90fdb01 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,4 +1,4 @@ -module git.max-richter.dev/max/marka/server-new +module git.max-richter.dev/max/marka/server go 1.24.7 diff --git a/server/internal/adapters/fs.go b/server/internal/adapters/fs.go index 83dcecd..a080257 100644 --- a/server/internal/adapters/fs.go +++ b/server/internal/adapters/fs.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "git.max-richter.dev/max/marka/parser" @@ -12,52 +13,70 @@ import ( type LocalFsAdapter struct { roots []string + cache map[string]*Entry + mu sync.RWMutex } -func (l LocalFsAdapter) readDir(path string, root string) (FsResponse, error) { +func (l *LocalFsAdapter) readDir(path string, root string) (*Entry, error) { dirInfo, err := os.Stat(path) if err != nil { - return FsResponse{}, err + return nil, err } entries, err := os.ReadDir(path) if err != nil { - return FsResponse{}, err + return nil, err } - out := make([]FsDirEntry, 0, len(entries)) + out := make([]*Entry, 0, len(entries)) for _, e := range entries { info, _ := e.Info() entryType := "dir" if !e.IsDir() { - entryType = contentTypeFor(e.Name()) + entryType = "file" } var content any - if !e.IsDir() && entryType == "application/markdown" { - entryPath := filepath.Join(path, e.Name()) - fileContent, err := os.ReadFile(entryPath) - if err == nil { - parsedContent, err := parser.ParseFile(string(fileContent)) + var mime string + var size int64 + if !e.IsDir() { + mime = contentTypeFor(e.Name()) + if info != nil { + size = info.Size() + } + if mime == "application/markdown" { + entryPath := filepath.Join(path, e.Name()) + fileContent, err := os.ReadFile(entryPath) if err == nil { - content = parsedContent + parsedContent, err := parser.ParseFile(string(fileContent)) + if err == nil { + content = parsedContent + } } } } - out = append(out, FsDirEntry{ + childPath, err := filepath.Rel(root, filepath.Join(path, e.Name())) + if err != nil { + return nil, err + } + responseChildPath := "/" + filepath.ToSlash(filepath.Join(filepath.Base(root), childPath)) + + out = append(out, &Entry{ Name: e.Name(), + Path: responseChildPath, Type: entryType, - IsDir: e.IsDir(), ModTime: info.ModTime(), + MIME: mime, + Size: size, Content: content, }) } relPath, err := filepath.Rel(root, path) if err != nil { - return FsResponse{}, err + return nil, err } if relPath == "." { relPath = "" @@ -65,75 +84,111 @@ func (l LocalFsAdapter) readDir(path string, root string) (FsResponse, error) { responsePath := "/" + filepath.ToSlash(filepath.Join(filepath.Base(root), relPath)) - return FsResponse{ - Dir: &FsDir{ - Files: out, - Name: responsePath, - ModTime: dirInfo.ModTime(), - }, + return &Entry{ + Type: "dir", + Name: filepath.Base(responsePath), + Path: responsePath, + ModTime: dirInfo.ModTime(), + Content: out, }, nil } -func (l LocalFsAdapter) readFile(path string, root string) (FsResponse, error) { +func (l *LocalFsAdapter) readFile(path string, root string) (*Entry, error) { fi, err := os.Stat(path) if err != nil { - return FsResponse{}, err - } - - data, err := os.ReadFile(path) - if err != nil { - return FsResponse{}, err + return nil, err } relPath, err := filepath.Rel(root, path) if err != nil { - return FsResponse{}, err + return nil, err } responsePath := "/" + filepath.ToSlash(filepath.Join(filepath.Base(root), relPath)) + mime := contentTypeFor(path) + var content any - return FsResponse{ - File: &FsFile{ - Name: responsePath, - Type: contentTypeFor(path), - ModTime: fi.ModTime(), - Content: data, - }, + fileContent, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + if mime == "application/markdown" { + parsedContent, err := parser.ParseFile(string(fileContent)) + if err == nil { + content = parsedContent + } else { + // Fallback to raw content on parsing error + content = fileContent + } + } else { + content = fileContent + } + + return &Entry{ + Type: "file", + Name: fi.Name(), + Path: responsePath, + ModTime: fi.ModTime(), + MIME: mime, + Size: fi.Size(), + Content: content, }, nil } -func (l LocalFsAdapter) Read(path string) (FsResponse, error) { +func (l *LocalFsAdapter) Read(path string) (*Entry, error) { if path == "/" { - entries := make([]FsDirEntry, 0, len(l.roots)) var latestModTime time.Time for _, r := range l.roots { info, err := os.Stat(r) if err != nil { continue } - entries = append(entries, FsDirEntry{ - Name: filepath.Base(r), - Type: "dir", - IsDir: true, - ModTime: info.ModTime(), - }) if info.ModTime().After(latestModTime) { latestModTime = info.ModTime() } } - return FsResponse{ - Dir: &FsDir{ - Files: entries, - Name: "/", - ModTime: latestModTime, - }, - }, nil + l.mu.RLock() + cached, found := l.cache[path] + l.mu.RUnlock() + + if found && !latestModTime.After(cached.ModTime) { + return cached, nil + } + + entries := make([]*Entry, 0, len(l.roots)) + for _, r := range l.roots { + info, err := os.Stat(r) + if err != nil { + continue + } + name := filepath.Base(r) + entries = append(entries, &Entry{ + Name: name, + Path: "/" + name, + Type: "dir", + ModTime: info.ModTime(), + }) + } + + entry := &Entry{ + Type: "dir", + Name: "/", + Path: "/", + ModTime: latestModTime, + Content: entries, + } + + l.mu.Lock() + l.cache[path] = entry + l.mu.Unlock() + return entry, nil } pathParts := strings.Split(strings.Trim(path, "/"), "/") if len(pathParts) == 0 || pathParts[0] == "" { - return FsResponse{}, ErrNotFound + return nil, ErrNotFound } rootIdentifier := pathParts[0] @@ -146,7 +201,7 @@ func (l LocalFsAdapter) Read(path string) (FsResponse, error) { } if targetRoot == "" { - return FsResponse{}, ErrNotFound + return nil, ErrNotFound } subPath := filepath.Join(pathParts[1:]...) @@ -154,38 +209,58 @@ func (l LocalFsAdapter) Read(path string) (FsResponse, error) { absTarget, err := filepath.Abs(target) if err != nil { - return FsResponse{}, err + return nil, err } absRoot, err := filepath.Abs(targetRoot) if err != nil { - return FsResponse{}, err + return nil, err } if !strings.HasPrefix(absTarget, absRoot) { - return FsResponse{}, errors.New("path escapes root") + return nil, errors.New("path escapes root") } fi, err := os.Stat(target) if err != nil { if os.IsNotExist(err) { - return FsResponse{}, ErrNotFound + return nil, ErrNotFound } - return FsResponse{}, err + return nil, err } + l.mu.RLock() + cached, found := l.cache[path] + l.mu.RUnlock() + + if found && !fi.ModTime().After(cached.ModTime) { + return cached, nil + } + + var newEntry *Entry if fi.IsDir() { - return l.readDir(target, targetRoot) + newEntry, err = l.readDir(target, targetRoot) + } else { + newEntry, err = l.readFile(target, targetRoot) } - return l.readFile(target, targetRoot) + if err != nil { + return nil, err + } + + l.mu.Lock() + l.cache[path] = newEntry + l.mu.Unlock() + + return newEntry, nil } -func (LocalFsAdapter) Write(path string, content []byte) error { +func (l *LocalFsAdapter) Write(path string, content []byte) error { return nil } func NewLocalFsAdapter(roots []string) (FileAdapter, error) { - return LocalFsAdapter{ + return &LocalFsAdapter{ roots: roots, + cache: make(map[string]*Entry), }, nil } diff --git a/server/internal/adapters/fs_utils.go b/server/internal/adapters/fs_utils.go index 8ebdcde..4ea2bd8 100644 --- a/server/internal/adapters/fs_utils.go +++ b/server/internal/adapters/fs_utils.go @@ -3,10 +3,8 @@ package adapters import ( "errors" "mime" - "os" "path/filepath" "strings" - "time" ) func SafeRel(root, requested string) (string, error) { @@ -37,20 +35,6 @@ func ResponsePath(root, full string) string { return "/" + filepath.ToSlash(rel) } -func sizeOrZero(fi os.FileInfo) int64 { - if fi == nil { - return 0 - } - return fi.Size() -} - -func modTimeOrZero(fi os.FileInfo) time.Time { - if fi == nil { - return time.Time{} - } - return fi.ModTime() -} - var textPlainExtensions = map[string]bool{ ".txt": true, ".log": true, diff --git a/server/internal/adapters/interface.go b/server/internal/adapters/interface.go index 0cb606c..2fd6e95 100644 --- a/server/internal/adapters/interface.go +++ b/server/internal/adapters/interface.go @@ -3,33 +3,26 @@ package adapters import "time" +// Entry represents a file or directory in the filesystem. +type Entry struct { + Type string `json:"type"` // "file" | "dir" + Name string `json:"name"` // base name + Path string `json:"path"` // full path or virtual path + ModTime time.Time `json:"modTime"` // last modified + + // File-only (optional) + MIME string `json:"mime,omitempty"` // e.g. "text/markdown" + Size int64 `json:"size,omitempty"` // bytes + + // Content: + // - markdown file: any (parsed AST / arbitrary JSON) + // - directory: []*Entry (children) + // - other files: omitted + Content any `json:"content,omitempty"` +} + +// FileAdapter is the interface for accessing the file system. type FileAdapter interface { - Read(path string) (FsResponse, error) + Read(path string) (*Entry, error) Write(path string, content []byte) error -} - -type FsFile struct { - Name string `json:"name"` - Type string `json:"type"` - Content []byte `json:"content"` - ModTime time.Time `json:"modTime"` -} - -type FsDirEntry struct { - Name string `json:"name"` - Type string `json:"type"` - IsDir bool `json:"isDir,omitempty"` - ModTime time.Time `json:"modTime"` - Content any `json:"content,omitempty"` -} - -type FsDir struct { - Files []FsDirEntry `json:"files"` - Name string `json:"name"` - ModTime time.Time `json:"modTime"` -} - -type FsResponse struct { - Dir *FsDir `json:"dir,omitempty"` - File *FsFile `json:"file,omitempty"` -} +} \ No newline at end of file diff --git a/server/internal/handler/error.go b/server/internal/handler/error.go index 0fe486c..37fcc3d 100644 --- a/server/internal/handler/error.go +++ b/server/internal/handler/error.go @@ -3,8 +3,6 @@ package handler import ( "encoding/json" "net/http" - - "git.max-richter.dev/max/marka/server-new/internal/adapters" ) type ErrorResponse struct { @@ -20,8 +18,3 @@ func writeJSON(w http.ResponseWriter, code int, v any) { w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v) } - -func writeFile(w http.ResponseWriter, file *adapters.FsFile) { - w.Header().Set("Content-Type", file.Type) - w.Write(file.Content) -} diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index ca10056..0b063fc 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -4,68 +4,44 @@ package handler import ( "errors" "net/http" - "time" - "git.max-richter.dev/max/marka/parser" - "git.max-richter.dev/max/marka/server-new/internal/adapters" + "git.max-richter.dev/max/marka/server/internal/adapters" ) -type ResponseItem struct { - Name string `json:"name"` - Content any `json:"content,omitempty"` - Type string `json:"type,omitempty"` - IsDir bool `json:"isDir"` - Size int64 `json:"size,omitempty"` - ModTime time.Time `json:"modTime"` -} - type Handler struct { adapter adapters.FileAdapter } func (h *Handler) get(w http.ResponseWriter, target string) { - fsEntry, err := h.adapter.Read(target) + entry, err := h.adapter.Read(target) if err != nil { - writeError(w, 500, err) - return - } - - if fsEntry.File != nil { - - if fsEntry.File.Content != nil && fsEntry.File.Type == "application/markdown" { - data, err := parser.ParseFile(string(fsEntry.File.Content)) - if err != nil { - writeError(w, 500, err) - return - } - - res := ResponseItem{ - Name: fsEntry.File.Name, - Type: fsEntry.File.Type, - Content: data, - IsDir: false, - Size: int64(len(fsEntry.File.Content)), - ModTime: fsEntry.File.ModTime, - } - - writeJSON(w, 200, res) + if errors.Is(err, adapters.ErrNotFound) { + writeError(w, http.StatusNotFound, err) return } - - writeFile(w, fsEntry.File) + writeError(w, http.StatusInternalServerError, err) return } - if fsEntry.Dir != nil { - res := ResponseItem{ - Name: fsEntry.Dir.Name, - Content: fsEntry.Dir.Files, - IsDir: true, - ModTime: fsEntry.Dir.ModTime, + if entry.Type == "file" { + // For non-markdown files, content is []byte, write it directly + if contentBytes, ok := entry.Content.([]byte); ok { + w.Header().Set("Content-Type", entry.MIME) + w.Write(contentBytes) + return } - writeJSON(w, 200, res) + // For markdown files, content is parsed, return the whole Entry as JSON + writeJSON(w, http.StatusOK, entry) return } + + // For directories, return the whole Entry as JSON + if entry.Type == "dir" { + writeJSON(w, http.StatusOK, entry) + return + } + + writeError(w, http.StatusInternalServerError, errors.New("unknown entry type")) } func (h *Handler) post(w http.ResponseWriter, target string) {