feat: simplify data and add cache to LocalFsAdapter
This commit is contained in:
15
server/README.md
Normal file
15
server/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## Marka Server
|
||||
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Recipe",
|
||||
"modTime": 123123123123,
|
||||
"content": [
|
||||
{
|
||||
"name": "Baguette",
|
||||
"modTime": 123123123123,
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.max-richter.dev/max/marka/parser"
|
||||
@@ -12,30 +13,39 @@ 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" {
|
||||
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 {
|
||||
@@ -45,19 +55,28 @@ func (l LocalFsAdapter) readDir(path string, root string) (FsResponse, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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),
|
||||
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(),
|
||||
Content: data,
|
||||
},
|
||||
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,
|
||||
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,
|
||||
},
|
||||
}, nil
|
||||
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
|
||||
}
|
||||
|
||||
func (LocalFsAdapter) Write(path string, content []byte) error {
|
||||
l.mu.Lock()
|
||||
l.cache[path] = newEntry
|
||||
l.mu.Unlock()
|
||||
|
||||
return newEntry, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -3,33 +3,26 @@ package adapters
|
||||
|
||||
import "time"
|
||||
|
||||
type FileAdapter interface {
|
||||
Read(path string) (FsResponse, error)
|
||||
Write(path string, content []byte) error
|
||||
}
|
||||
// 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
|
||||
|
||||
type FsFile struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content []byte `json:"content"`
|
||||
ModTime time.Time `json:"modTime"`
|
||||
}
|
||||
// File-only (optional)
|
||||
MIME string `json:"mime,omitempty"` // e.g. "text/markdown"
|
||||
Size int64 `json:"size,omitempty"` // bytes
|
||||
|
||||
type FsDirEntry struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsDir bool `json:"isDir,omitempty"`
|
||||
ModTime time.Time `json:"modTime"`
|
||||
// Content:
|
||||
// - markdown file: any (parsed AST / arbitrary JSON)
|
||||
// - directory: []*Entry (children)
|
||||
// - other files: omitted
|
||||
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"`
|
||||
// FileAdapter is the interface for accessing the file system.
|
||||
type FileAdapter interface {
|
||||
Read(path string) (*Entry, error)
|
||||
Write(path string, content []byte) error
|
||||
}
|
@@ -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)
|
||||
}
|
||||
|
@@ -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)
|
||||
if errors.Is(err, adapters.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, 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)
|
||||
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
|
||||
}
|
||||
// For markdown files, content is parsed, return the whole Entry as JSON
|
||||
writeJSON(w, http.StatusOK, entry)
|
||||
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)
|
||||
// For directories, return the whole Entry as JSON
|
||||
if entry.Type == "dir" {
|
||||
writeJSON(w, http.StatusOK, entry)
|
||||
return
|
||||
}
|
||||
|
||||
writeFile(w, fsEntry.File)
|
||||
return
|
||||
}
|
||||
|
||||
if fsEntry.Dir != nil {
|
||||
res := ResponseItem{
|
||||
Name: fsEntry.Dir.Name,
|
||||
Content: fsEntry.Dir.Files,
|
||||
IsDir: true,
|
||||
ModTime: fsEntry.Dir.ModTime,
|
||||
}
|
||||
writeJSON(w, 200, res)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, errors.New("unknown entry type"))
|
||||
}
|
||||
|
||||
func (h *Handler) post(w http.ResponseWriter, target string) {
|
||||
|
Reference in New Issue
Block a user