package adapters import ( "errors" "os" "path/filepath" "strings" "sync" "time" "git.max-richter.dev/max/marka/parser" ) type LocalFsAdapter struct { roots []string cache map[string]*Entry mu sync.RWMutex } func (l *LocalFsAdapter) readDir(path string, root string) (*Entry, error) { dirInfo, err := os.Stat(path) if err != nil { return nil, err } entries, err := os.ReadDir(path) if err != nil { return nil, err } out := make([]*Entry, 0, len(entries)) for _, e := range entries { info, _ := e.Info() entryType := "dir" if !e.IsDir() { entryType = "file" } var content any 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 { parsedContent, err := parser.ParseFile(string(fileContent)) if err == nil { content = parsedContent } } } } 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, ModTime: info.ModTime(), MIME: mime, Size: size, Content: content, }) } relPath, err := filepath.Rel(root, path) if err != nil { return nil, err } if relPath == "." { relPath = "" } responsePath := "/" + filepath.ToSlash(filepath.Join(filepath.Base(root), relPath)) return &Entry{ Type: "dir", Name: filepath.Base(responsePath), Path: responsePath, ModTime: dirInfo.ModTime(), Content: out, }, nil } func (l *LocalFsAdapter) readFile(path string, root string) (*Entry, error) { fi, err := os.Stat(path) if err != nil { return nil, err } relPath, err := filepath.Rel(root, path) if err != nil { return nil, err } responsePath := "/" + filepath.ToSlash(filepath.Join(filepath.Base(root), relPath)) mime := contentTypeFor(path) var content any 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) (*Entry, error) { if path == "/" { var latestModTime time.Time for _, r := range l.roots { info, err := os.Stat(r) if err != nil { continue } if info.ModTime().After(latestModTime) { latestModTime = info.ModTime() } } 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 nil, ErrNotFound } rootIdentifier := pathParts[0] var targetRoot string for _, r := range l.roots { if filepath.Base(r) == rootIdentifier { targetRoot = r break } } if targetRoot == "" { return nil, ErrNotFound } subPath := filepath.Join(pathParts[1:]...) target := filepath.Join(targetRoot, subPath) absTarget, err := filepath.Abs(target) if err != nil { return nil, err } absRoot, err := filepath.Abs(targetRoot) if err != nil { return nil, err } if !strings.HasPrefix(absTarget, absRoot) { return nil, errors.New("path escapes root") } fi, err := os.Stat(target) if err != nil { if os.IsNotExist(err) { return nil, ErrNotFound } 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() { newEntry, err = l.readDir(target, targetRoot) } else { newEntry, err = l.readFile(target, targetRoot) } if err != nil { return nil, err } 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{ roots: roots, cache: make(map[string]*Entry), }, nil }