From 9068d8ba8e0d6683c09e7516d1230436894c7417 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Wed, 26 Aug 2026 19:16:53 +0200 Subject: [PATCH] Extract Files seam, WebDAV, and Visit from CheckDNS. Publish go-nc-files v1 with local/WebDAV Root, DefaultPathStore, and neutral Working Folder language; depends on go-nc-exapp v0.1.0. Co-authored-by: Cursor --- CONTEXT.md | 25 +++ README.md | 57 +++++++ defaultpath.go | 65 ++++++++ doc.go | 3 + errors.go | 13 ++ files.go | 191 ++++++++++++++++++++++ files_test.go | 100 ++++++++++++ go.mod | 5 + go.sum | 2 + visit.go | 93 +++++++++++ visit_test.go | 137 ++++++++++++++++ visit_webdav_test.go | 126 +++++++++++++++ webdav.go | 372 +++++++++++++++++++++++++++++++++++++++++++ webdav_test.go | 119 ++++++++++++++ 14 files changed, 1308 insertions(+) create mode 100644 CONTEXT.md create mode 100644 README.md create mode 100644 defaultpath.go create mode 100644 doc.go create mode 100644 errors.go create mode 100644 files.go create mode 100644 files_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 visit.go create mode 100644 visit_test.go create mode 100644 visit_webdav_test.go create mode 100644 webdav.go create mode 100644 webdav_test.go diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..3fffc8a --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,25 @@ +# go-nc-files + +Shared Go Library for reading and writing a Nextcloud user's files from ExApp Services: a storage seam (local and WebDAV), path hygiene, saved default folder preferences, and visit-scoped folder resolution. Depends on go-nc-exapp for AppAPI credentials on WebDAV. ExApps import `gitea.neitzel.de/konrad/go-nc-files`. + +## Language + +**User Files Root**: +The root of that Nextcloud user's files tree. Working Folder paths are relative to it (including paths that appear in shares as the user sees them). +_Avoid_: absolute WebDAV URLs, numeric file IDs + +**Working Folder**: +The single folder an ExApp uses for the current operation. Its path is relative to the User Files Root. Each ExApp defines what files inside that folder mean; this Library only opens, lists, reads, and writes there. +_Avoid_: catalog folder, zones directory, app-specific suffixes in Library code + +**Saved Default**: +The Working Folder path persisted for app-icon launches for that user. Stored via a `DefaultPathStore` implementation (ExApp preferences in production, a JSON file in local/test mode). The ExApp supplies the preference key and initial folder name. +_Avoid_: visit-only path as saved default, admin AppConfig + +**Visit**: +One inbound ExApp request bound to one Working Folder. App-icon launch uses the Saved Default (creating the initial folder empty if missing). Files-view launch may pass a User Files Root–relative visit path for that request only; if that folder is missing or forbidden, the Visit fails — no fallback to Saved Default and no implicit create. +_Avoid_: silently switching folders, creating a visit path that was not saved as default + +**Files seam**: +The `Root` and `Folder` interfaces: open or ensure a relative path, then list, read, write, and test existence. Local disk and WebDAV are two implementations of the same seam. +_Avoid_: coupling the seam to a specific on-disk layout beyond basename rules diff --git a/README.md b/README.md new file mode 100644 index 0000000..8767544 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# go-nc-files + +Shared Go library for Nextcloud ExApp Services: a User Files seam (local disk and WebDAV), Saved Default path storage, and Visit-scoped Working Folder resolution. + +Import: `gitea.neitzel.de/konrad/go-nc-files` + +Depends on [go-nc-exapp](https://gitea.neitzel.de/konrad/go-nc-exapp) for AppAPI credentials on WebDAV. + +## v1 scope + +**Included** + +- **Files seam** — `Root` and `Folder` interfaces with `OpenFolder`, `EnsureFolder`, `List`, `Read`, `Write`, `Exists` +- **Local** — filesystem-backed User Files Root for tests and local `go run` +- **WebDAVRoot** — Nextcloud Files at `/remote.php/dav/files/{user}/` using `gonexapp.Credentials` +- **CleanRel** — rejects `..`, URLs, and numeric file IDs +- **RequireBasename** — shared basename rules for child file names +- **DefaultPathStore** — `GetDefault` / `SetDefault` for Saved Default paths; `FileStore` (JSON) and `Memory` implementations +- **Visit** — `Open` resolves Working Folder from Saved Default (app-icon) or visit-only path (Files view); fail-closed on missing/forbidden visit paths +- Sentinel errors: `ErrNotExist`, `ErrForbidden`, `ErrNotDir` + +**Excluded from v1** + +- ExApp lifecycle HTTP routes and HaRP bootstrap (see **go-nc-exapp**) +- AppAPI preference OCS wiring (ExApp adapts `gonexapp.AppAPIPreferences` to `DefaultPathStore`) +- Catalog, DNS, or product-specific file semantics +- Recursive tree walks and Nextcloud numeric file IDs + +## Usage + +```go +import ( + goncfiles "gitea.neitzel.de/konrad/go-nc-files" + gonexapp "gitea.neitzel.de/konrad/go-nc-exapp" +) + +// Local mode +visit, err := goncfiles.OpenLocal("/data/files", "/data/settings.json", "myapp", "") + +// WebDAV + ExApp preferences (adapter in your ExApp) +cred := gonexapp.Credentials{ /* ... */ } +root := goncfiles.WebDAVRoot{Cred: cred} +prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", "savedDefault") +store := myAppAPIPathStore{prefs} // implement DefaultPathStore +visit, err := goncfiles.Open(root, store, "myapp", visitRelative) +``` + +Each ExApp chooses its Saved Default preference key and initial folder name; this library stays key-agnostic. + +## Domain language + +See [CONTEXT.md](./CONTEXT.md) for User Files Root, Working Folder, Saved Default, Visit, and Files seam terminology. + +## Related + +- **go-nc-exapp** — AppAPI credentials and OCS preferences +- Workspace ADR 0013 — extraction from CheckDNS diff --git a/defaultpath.go b/defaultpath.go new file mode 100644 index 0000000..81e48f0 --- /dev/null +++ b/defaultpath.go @@ -0,0 +1,65 @@ +package goncfiles + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// DefaultPathStore persists the Saved Default Working Folder path (User Files Root–relative). +type DefaultPathStore interface { + GetDefault() (string, error) + SetDefault(rel string) error +} + +// FileStore is the local-adapter settings JSON file. +type FileStore struct { + Path string +} + +type settingsFile struct { + DefaultPath string `json:"defaultPath"` +} + +// GetDefault reads the saved default; empty means the caller should use the product initial default. +func (s FileStore) GetDefault() (string, error) { + body, err := os.ReadFile(s.Path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + var sf settingsFile + if err := json.Unmarshal(body, &sf); err != nil { + return "", err + } + return strings.TrimSpace(sf.DefaultPath), nil +} + +// SetDefault writes the saved Working Folder path. +func (s FileStore) SetDefault(rel string) error { + body, err := json.MarshalIndent(settingsFile{DefaultPath: rel}, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.Path), 0o755); err != nil { + return err + } + return os.WriteFile(s.Path, append(body, '\n'), 0o644) +} + +// Memory is an in-memory DefaultPathStore for tests. +type Memory struct { + Value string +} + +// GetDefault returns the in-memory value. +func (m *Memory) GetDefault() (string, error) { return m.Value, nil } + +// SetDefault stores the value in memory. +func (m *Memory) SetDefault(rel string) error { + m.Value = rel + return nil +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..7685093 --- /dev/null +++ b/doc.go @@ -0,0 +1,3 @@ +// Package goncfiles provides a Nextcloud User Files seam (local and WebDAV), +// Saved Default path storage, and Visit-scoped Working Folder resolution for ExApp Services. +package goncfiles diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..3bde177 --- /dev/null +++ b/errors.go @@ -0,0 +1,13 @@ +package goncfiles + +import "errors" + +// IsNotExist reports whether err wraps ErrNotExist. +func IsNotExist(err error) bool { + return errors.Is(err, ErrNotExist) +} + +// IsForbidden reports whether err wraps ErrForbidden. +func IsForbidden(err error) bool { + return errors.Is(err, ErrForbidden) +} diff --git a/files.go b/files.go new file mode 100644 index 0000000..dd4d9ee --- /dev/null +++ b/files.go @@ -0,0 +1,191 @@ +package goncfiles + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strings" +) + +// Sentinel errors for Visit / Working Folder resolution. +var ( + ErrNotExist = errors.New("not found") + ErrForbidden = errors.New("forbidden") + ErrNotDir = errors.New("not a directory") +) + +// Entry is a first-level name in a Working Folder. +type Entry struct { + Name string + IsDir bool +} + +// Folder is flat storage for one Working Folder (basenames only). +type Folder interface { + List() ([]Entry, error) + Read(name string) ([]byte, error) + Write(name string, data []byte) error + Exists(name string) (bool, error) +} + +// Root is User Files Root–relative folder access. +type Root interface { + // OpenFolder opens an existing folder at rel (User Files Root–relative). + OpenFolder(rel string) (Folder, error) + // EnsureFolder creates rel (and parents) if needed, then opens it. + EnsureFolder(rel string) (Folder, error) +} + +// Local is a User Files Root on the local filesystem. +type Local struct { + RootDir string +} + +// OpenFolder opens an existing directory under the local root. +func (l Local) OpenFolder(rel string) (Folder, error) { + dir, err := l.resolve(rel) + if err != nil { + return nil, err + } + info, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotExist, rel) + } + if os.IsPermission(err) { + return nil, fmt.Errorf("%w: %s", ErrForbidden, rel) + } + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("%w: %s", ErrNotDir, rel) + } + return localFolder{dir: dir}, nil +} + +// EnsureFolder creates the directory if missing, then opens it. +func (l Local) EnsureFolder(rel string) (Folder, error) { + dir, err := l.resolve(rel) + if err != nil { + return nil, err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + if os.IsPermission(err) { + return nil, fmt.Errorf("%w: %s", ErrForbidden, rel) + } + return nil, err + } + return localFolder{dir: dir}, nil +} + +func (l Local) resolve(rel string) (string, error) { + clean := CleanRel(rel) + if clean == "" { + return "", fmt.Errorf("invalid Working Folder path") + } + return filepath.Join(l.RootDir, filepath.FromSlash(clean)), nil +} + +// CleanRel normalizes a User Files Root–relative path; empty means invalid. +// Absolute URLs and numeric file IDs are rejected. +func CleanRel(rel string) string { + rel = strings.TrimSpace(rel) + rel = strings.ReplaceAll(rel, "\\", "/") + lower := strings.ToLower(rel) + if strings.Contains(rel, "://") || strings.HasPrefix(lower, "http:") || strings.HasPrefix(lower, "https:") { + return "" + } + rel = strings.Trim(rel, "/") + if rel == "" || strings.Contains(rel, "..") { + return "" + } + // Bare numeric Nextcloud file IDs are not Working Folder paths. + if isAllDigits(rel) { + return "" + } + clean := path.Clean(rel) + if clean == "." || strings.HasPrefix(clean, "..") { + return "" + } + return clean +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +type localFolder struct { + dir string +} + +// LocalFolder returns a Folder backed by an absolute local directory. +func LocalFolder(dir string) Folder { + return localFolder{dir: dir} +} + +func (f localFolder) List() ([]Entry, error) { + entries, err := os.ReadDir(f.dir) + if err != nil { + return nil, err + } + out := make([]Entry, 0, len(entries)) + for _, e := range entries { + out = append(out, Entry{Name: e.Name(), IsDir: e.IsDir()}) + } + return out, nil +} + +func (f localFolder) Read(name string) ([]byte, error) { + if err := RequireBasename(name); err != nil { + return nil, err + } + return os.ReadFile(filepath.Join(f.dir, name)) +} + +func (f localFolder) Write(name string, data []byte) error { + if err := RequireBasename(name); err != nil { + return err + } + return os.WriteFile(filepath.Join(f.dir, name), data, 0o644) +} + +func (f localFolder) Exists(name string) (bool, error) { + if err := RequireBasename(name); err != nil { + return false, err + } + info, err := os.Stat(filepath.Join(f.dir, name)) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return !info.IsDir(), nil +} + +// RequireBasename rejects path segments that are not a single basename. +func RequireBasename(name string) error { + if name == "" || name != path.Base(name) || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { + return fmt.Errorf("name must be a basename in the Working Folder, not %q", name) + } + return nil +} + +// LocalPath returns the absolute directory when f is a local folder. +func LocalPath(f Folder) (string, bool) { + lf, ok := f.(localFolder) + if !ok { + return "", false + } + return lf.dir, true +} diff --git a/files_test.go b/files_test.go new file mode 100644 index 0000000..456fdd5 --- /dev/null +++ b/files_test.go @@ -0,0 +1,100 @@ +package goncfiles_test + +import ( + "os" + "path/filepath" + "testing" + + "gitea.neitzel.de/konrad/go-nc-files" +) + +func TestCleanRelRejectsURLsAndFileIDs(t *testing.T) { + t.Parallel() + for _, in := range []string{ + "https://nc.example/remote.php/dav/files/alice/myapp", + "http://x/y", + "12345", + "../etc", + "", + } { + if got := goncfiles.CleanRel(in); got != "" { + t.Fatalf("CleanRel(%q)=%q want empty", in, got) + } + } + if got := goncfiles.CleanRel("myapp"); got != "myapp" { + t.Fatalf("got %q", got) + } + if got := goncfiles.CleanRel("Shared/TeamDNS"); got != "Shared/TeamDNS" { + t.Fatalf("got %q", got) + } +} + +func TestLocalFolderReadWriteListExists(t *testing.T) { + t.Parallel() + root := t.TempDir() + sub := filepath.Join(root, "data") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + + local := goncfiles.Local{RootDir: root} + folder, err := local.OpenFolder("data") + if err != nil { + t.Fatal(err) + } + if err := folder.Write("hello.txt", []byte("hi")); err != nil { + t.Fatal(err) + } + ok, err := folder.Exists("hello.txt") + if err != nil || !ok { + t.Fatalf("exists: ok=%v err=%v", ok, err) + } + data, err := folder.Read("hello.txt") + if err != nil || string(data) != "hi" { + t.Fatalf("read: %q err=%v", data, err) + } + entries, err := folder.List() + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name != "hello.txt" || entries[0].IsDir { + t.Fatalf("list: %+v", entries) + } +} + +func TestLocalEnsureFolderCreatesPath(t *testing.T) { + t.Parallel() + root := t.TempDir() + local := goncfiles.Local{RootDir: root} + folder, err := local.EnsureFolder("a/b/c") + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "a", "b", "c")); err != nil { + t.Fatal(err) + } + if err := folder.Write("x", []byte("1")); err != nil { + t.Fatal(err) + } +} + +func TestLocalOpenMissingIsNotExist(t *testing.T) { + t.Parallel() + local := goncfiles.Local{RootDir: t.TempDir()} + _, err := local.OpenFolder("missing") + if err == nil { + t.Fatal("expected error") + } + if !goncfiles.IsNotExist(err) { + t.Fatalf("got %v", err) + } +} + +func TestRequireBasenameRejectsPathSegments(t *testing.T) { + t.Parallel() + for _, name := range []string{"", "a/b", "..", `a\b`} { + if err := goncfiles.RequireBasename(name); err == nil { + t.Fatalf("RequireBasename(%q) expected error", name) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0fa9a93 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitea.neitzel.de/konrad/go-nc-files + +go 1.26.4 + +require gitea.neitzel.de/konrad/go-nc-exapp v0.1.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..148faa5 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +gitea.neitzel.de/konrad/go-nc-exapp v0.1.0 h1:DKMBRQ2KjzRkYYyfJlbL6+qlFIn6UAbU2y5SsYzYdW0= +gitea.neitzel.de/konrad/go-nc-exapp v0.1.0/go.mod h1:5aGWJqbOZ4Uuz+CILGF2wMWwn4ssUB4EIjagN6y4Fhc= diff --git a/visit.go b/visit.go new file mode 100644 index 0000000..8180160 --- /dev/null +++ b/visit.go @@ -0,0 +1,93 @@ +package goncfiles + +import ( + "fmt" + "strings" +) + +// Visit is one ExApp opening: Working Folder for this request plus the Saved Default. +type Visit struct { + SavedDefault string + WorkingRel string + VisitRelative string + FS Folder + defaults DefaultPathStore + root Root +} + +// Open resolves the Working Folder for this visit. +// initialFolderName is used when no Saved Default is stored yet (app-icon first launch). +// visitRelative empty means app-icon launch (Saved Default, creating the initial folder empty if missing). +// A Files-view folder is used only for this visit and does not change the Saved Default. +// Missing or forbidden visit paths fail closed (no create, no fallback). +func Open(root Root, defaults DefaultPathStore, initialFolderName, visitRelative string) (*Visit, error) { + saved, err := defaults.GetDefault() + if err != nil { + return nil, err + } + if saved == "" { + saved = initialFolderName + } + if CleanRel(saved) == "" { + return nil, fmt.Errorf("invalid saved Working Folder path") + } + + raw := strings.Trim(strings.ReplaceAll(visitRelative, "\\", "/"), "/") + v := &Visit{ + SavedDefault: saved, + defaults: defaults, + root: root, + } + + if raw == "" { + fs, err := root.EnsureFolder(saved) + if err != nil { + return nil, err + } + v.WorkingRel = saved + v.FS = fs + return v, nil + } + + rel := CleanRel(raw) + if rel == "" { + return nil, fmt.Errorf("invalid Working Folder path") + } + v.VisitRelative = rel + fs, err := root.OpenFolder(rel) + if err != nil { + return nil, fmt.Errorf("Working Folder %q: %w", rel, err) + } + v.WorkingRel = rel + v.FS = fs + return v, nil +} + +// OpenLocal is the local-adapter convenience: files root + settings JSON. +func OpenLocal(filesRoot, settingsPath, initialFolderName, visitRelative string) (*Visit, error) { + return Open(Local{RootDir: filesRoot}, FileStore{Path: settingsPath}, initialFolderName, visitRelative) +} + +// SetDefault persists a new Saved Default and ensures the folder exists. +func (v *Visit) SetDefault(relative string) error { + rel := CleanRel(relative) + if rel == "" { + return fmt.Errorf("invalid Saved Default path") + } + if _, err := v.root.EnsureFolder(rel); err != nil { + return err + } + if err := v.defaults.SetDefault(rel); err != nil { + return err + } + v.SavedDefault = rel + return nil +} + +// WorkingDir returns a local absolute path when using the local adapter; otherwise WorkingRel. +func (v *Visit) WorkingDir() string { + if p, ok := LocalPath(v.FS); ok { + return p + } + return v.WorkingRel +} diff --git a/visit_test.go b/visit_test.go new file mode 100644 index 0000000..f4fdd0d --- /dev/null +++ b/visit_test.go @@ -0,0 +1,137 @@ +package goncfiles_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gitea.neitzel.de/konrad/go-nc-files" +) + +const testInitialFolder = "myapp" + +func TestAppIconLaunchCreatesInitialFolder(t *testing.T) { + t.Parallel() + root := t.TempDir() + settings := filepath.Join(t.TempDir(), "settings.json") + + visit, err := goncfiles.OpenLocal(root, settings, testInitialFolder, "") + if err != nil { + t.Fatal(err) + } + if visit.SavedDefault != testInitialFolder { + t.Fatalf("saved: %s", visit.SavedDefault) + } + info, err := os.Stat(filepath.Join(root, testInitialFolder)) + if err != nil || !info.IsDir() { + t.Fatalf("initial folder: %v", err) + } + if visit.WorkingDir() != filepath.Join(root, testInitialFolder) { + t.Fatalf("working: %s", visit.WorkingDir()) + } +} + +func TestSetDefaultPersistsAndDoesNotNeedVisitFolder(t *testing.T) { + t.Parallel() + root := t.TempDir() + settings := filepath.Join(t.TempDir(), "settings.json") + if err := os.Mkdir(filepath.Join(root, "Zones"), 0o755); err != nil { + t.Fatal(err) + } + + visit, err := goncfiles.OpenLocal(root, settings, testInitialFolder, "") + if err != nil { + t.Fatal(err) + } + if err := visit.SetDefault("Zones"); err != nil { + t.Fatal(err) + } + + again, err := goncfiles.OpenLocal(root, settings, testInitialFolder, "") + if err != nil { + t.Fatal(err) + } + if again.SavedDefault != "Zones" { + t.Fatalf("saved: %s", again.SavedDefault) + } + if again.WorkingDir() != filepath.Join(root, "Zones") { + t.Fatalf("working: %s", again.WorkingDir()) + } +} + +func TestFilesViewLaunchDoesNotChangeSavedDefault(t *testing.T) { + t.Parallel() + root := t.TempDir() + settings := filepath.Join(t.TempDir(), "settings.json") + share := filepath.Join(root, "Shared", "TeamDNS") + if err := os.MkdirAll(share, 0o755); err != nil { + t.Fatal(err) + } + + visit, err := goncfiles.OpenLocal(root, settings, testInitialFolder, "Shared/TeamDNS") + if err != nil { + t.Fatal(err) + } + if visit.SavedDefault != testInitialFolder { + t.Fatalf("saved default must stay %s, got %s", testInitialFolder, visit.SavedDefault) + } + if visit.WorkingDir() != share { + t.Fatalf("visit working: %s", visit.WorkingDir()) + } + if _, err := os.Stat(filepath.Join(root, testInitialFolder)); err == nil { + t.Fatal("files-view launch must not create the initial folder") + } +} + +func TestFilesViewMissingFolderFailsClosed(t *testing.T) { + t.Parallel() + root := t.TempDir() + settings := filepath.Join(t.TempDir(), "settings.json") + + _, err := goncfiles.OpenLocal(root, settings, testInitialFolder, "Missing/Folder") + if err == nil { + t.Fatal("expected error") + } + if !goncfiles.IsNotExist(err) { + t.Fatalf("want ErrNotExist, got %v", err) + } + if _, err := os.Stat(filepath.Join(root, "Missing")); err == nil { + t.Fatal("must not create the visit path") + } + if _, err := os.Stat(filepath.Join(root, testInitialFolder)); err == nil { + t.Fatal("must not fall back to creating initial folder") + } +} + +func TestFileStorePersistsDefaultPath(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "settings.json") + store := goncfiles.FileStore{Path: path} + if err := store.SetDefault("Zones"); err != nil { + t.Fatal(err) + } + got, err := store.GetDefault() + if err != nil || got != "Zones" { + t.Fatalf("got %q err=%v", got, err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"defaultPath"`) { + t.Fatalf("expected defaultPath key in %s", body) + } +} + +func TestMemoryStore(t *testing.T) { + t.Parallel() + var m goncfiles.Memory + if err := m.SetDefault("a"); err != nil { + t.Fatal(err) + } + got, err := m.GetDefault() + if err != nil || got != "a" { + t.Fatalf("got %q err=%v", got, err) + } +} diff --git a/visit_webdav_test.go b/visit_webdav_test.go new file mode 100644 index 0000000..45d71ac --- /dev/null +++ b/visit_webdav_test.go @@ -0,0 +1,126 @@ +package goncfiles_test + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gonexapp "gitea.neitzel.de/konrad/go-nc-exapp" + "gitea.neitzel.de/konrad/go-nc-files" +) + +const testPrefKey = "savedDefault" + +// appAPIPathStore adapts go-nc-exapp AppAPIPreferences to DefaultPathStore (ExApp wiring pattern). +type appAPIPathStore struct { + gonexapp.AppAPIPreferences +} + +func (a appAPIPathStore) GetDefault() (string, error) { return a.Get() } +func (a appAPIPathStore) SetDefault(v string) error { return a.Set(v) } + +func TestWebDAVModeUsesPreferencesNotSettingsFile(t *testing.T) { + dirs := map[string]bool{} + prefsVals := map[string]string{} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "ex-app/preference/get-values"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "ocs": map[string]any{ + "data": []map[string]string{{ + "configkey": testPrefKey, + "configvalue": prefsVals[testPrefKey], + }}, + }, + }) + case strings.HasSuffix(r.URL.Path, "ex-app/preference") && r.Method == http.MethodPost: + var body struct { + ConfigKey string `json:"configKey"` + ConfigValue string `json:"configValue"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + prefsVals[body.ConfigKey] = body.ConfigValue + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + case r.Method == "MKCOL": + dirs[strings.TrimSuffix(r.URL.Path, "/")] = true + w.WriteHeader(http.StatusCreated) + case r.Method == "PROPFIND": + p := strings.TrimSuffix(r.URL.Path, "/") + if !dirs[p] { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusMultiStatus) + _, _ = io.WriteString(w, ``+r.URL.Path+``) + default: + http.Error(w, "unhandled "+r.Method+" "+r.URL.Path, 500) + } + })) + t.Cleanup(srv.Close) + + cred := gonexapp.Credentials{ + BaseURL: srv.URL, AppID: "myexapp", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice", + } + root := goncfiles.WebDAVRoot{Cred: cred, Client: srv.Client()} + prefs := gonexapp.NewAppAPIPreferences(cred, "myexapp", testPrefKey) + prefs.Client = srv.Client() + prefs.OCS.Client = srv.Client() + store := appAPIPathStore{AppAPIPreferences: prefs} + + visit, err := goncfiles.Open(root, store, testInitialFolder, "") + if err != nil { + t.Fatal(err) + } + if visit.SavedDefault != testInitialFolder { + t.Fatalf("saved %s", visit.SavedDefault) + } + if err := visit.SetDefault("Zones"); err != nil { + t.Fatal(err) + } + if prefsVals[testPrefKey] != "Zones" { + t.Fatalf("prefs not written: %#v", prefsVals) + } + + again, err := goncfiles.Open(root, store, testInitialFolder, "") + if err != nil { + t.Fatal(err) + } + if again.SavedDefault != "Zones" { + t.Fatalf("got %s", again.SavedDefault) + } +} + +func TestWebDAVVisitForbiddenFailsClosed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no", http.StatusForbidden) + })) + t.Cleanup(srv.Close) + cred := gonexapp.Credentials{ + BaseURL: srv.URL, AppID: "myexapp", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice", + } + _, err := goncfiles.Open( + goncfiles.WebDAVRoot{Cred: cred, Client: srv.Client()}, + &goncfiles.Memory{}, + testInitialFolder, + "Secret", + ) + if err == nil || !goncfiles.IsForbidden(err) { + t.Fatalf("got %v", err) + } +} + +func TestUserFromRequestRoundTrip(t *testing.T) { + token := base64.StdEncoding.EncodeToString([]byte("alice:secret")) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("AUTHORIZATION-APP-API", token) + user, err := gonexapp.UserFromRequest(r) + if err != nil || user != "alice" { + t.Fatalf("user=%q err=%v", user, err) + } +} diff --git a/webdav.go b/webdav.go new file mode 100644 index 0000000..466ff85 --- /dev/null +++ b/webdav.go @@ -0,0 +1,372 @@ +package goncfiles + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + + gonexapp "gitea.neitzel.de/konrad/go-nc-exapp" +) + +// WebDAVRoot is a User Files Root at /remote.php/dav/files/{user}/. +type WebDAVRoot struct { + Cred gonexapp.Credentials + Client *http.Client +} + +func (r WebDAVRoot) client() *http.Client { + if r.Client != nil { + return r.Client + } + return http.DefaultClient +} + +func (r WebDAVRoot) filesBase() string { + return strings.TrimRight(r.Cred.BaseURL, "/") + "/remote.php/dav/files/" + url.PathEscape(r.Cred.UserID) + "/" +} + +func (r WebDAVRoot) urlFor(rel string) (string, error) { + clean := CleanRel(rel) + if clean == "" && rel != "" && rel != "." { + return "", fmt.Errorf("invalid path") + } + base := r.filesBase() + if clean == "" { + return base, nil + } + parts := strings.Split(clean, "/") + escaped := make([]string, len(parts)) + for i, p := range parts { + escaped[i] = url.PathEscape(p) + } + return base + strings.Join(escaped, "/"), nil +} + +// OpenFolder opens an existing folder via WebDAV PROPFIND. +func (r WebDAVRoot) OpenFolder(rel string) (Folder, error) { + clean := CleanRel(rel) + if clean == "" { + return nil, fmt.Errorf("invalid Working Folder path") + } + u, err := r.urlFor(clean) + if err != nil { + return nil, err + } + if err := r.propfindExists(u); err != nil { + return nil, err + } + return webdavFolder{root: r, rel: clean}, nil +} + +// EnsureFolder creates the folder (and parents) with MKCOL, then opens it. +func (r WebDAVRoot) EnsureFolder(rel string) (Folder, error) { + clean := CleanRel(rel) + if clean == "" { + return nil, fmt.Errorf("invalid Working Folder path") + } + parts := strings.Split(clean, "/") + var built []string + for _, p := range parts { + built = append(built, p) + cur := strings.Join(built, "/") + u, err := r.urlFor(cur) + if err != nil { + return nil, err + } + err = r.propfindExists(u) + if err == nil { + continue + } + if !isNotExist(err) { + return nil, err + } + if err := r.mkcol(u); err != nil { + return nil, err + } + } + return webdavFolder{root: r, rel: clean}, nil +} + +func isNotExist(err error) bool { + return err != nil && strings.Contains(err.Error(), ErrNotExist.Error()) +} + +func (r WebDAVRoot) propfindExists(u string) error { + req, err := http.NewRequest("PROPFIND", u, strings.NewReader( + ``, + )) + if err != nil { + return err + } + req.Header.Set("Depth", "0") + req.Header.Set("Content-Type", "application/xml") + for k, vs := range r.Cred.AuthHeaders() { + for _, v := range vs { + req.Header.Set(k, v) + } + } + res, err := r.client().Do(req) + if err != nil { + return err + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + switch res.StatusCode { + case http.StatusMultiStatus, http.StatusOK: + if !propfindIsCollection(body) { + return fmt.Errorf("%w", ErrNotDir) + } + return nil + case http.StatusNotFound: + return fmt.Errorf("%w", ErrNotExist) + case http.StatusForbidden, http.StatusUnauthorized: + return fmt.Errorf("%w", ErrForbidden) + default: + return fmt.Errorf("webdav PROPFIND %s: %s", u, res.Status) + } +} + +func propfindIsCollection(body []byte) bool { + return bytes.Contains(body, []byte("collection")) +} + +func (r WebDAVRoot) mkcol(u string) error { + req, err := http.NewRequest("MKCOL", u, nil) + if err != nil { + return err + } + for k, vs := range r.Cred.AuthHeaders() { + for _, v := range vs { + req.Header.Set(k, v) + } + } + res, err := r.client().Do(req) + if err != nil { + return err + } + defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + if res.StatusCode == http.StatusCreated || res.StatusCode == http.StatusMethodNotAllowed { + return nil + } + if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("%w", ErrForbidden) + } + return fmt.Errorf("webdav MKCOL %s: %s", u, res.Status) +} + +type webdavFolder struct { + root WebDAVRoot + rel string +} + +func (f webdavFolder) childURL(name string) (string, error) { + if err := RequireBasename(name); err != nil { + return "", err + } + return f.root.urlFor(path.Join(f.rel, name)) +} + +func (f webdavFolder) List() ([]Entry, error) { + u, err := f.root.urlFor(f.rel) + if err != nil { + return nil, err + } + req, err := http.NewRequest("PROPFIND", u, strings.NewReader( + ``, + )) + if err != nil { + return nil, err + } + req.Header.Set("Depth", "1") + req.Header.Set("Content-Type", "application/xml") + for k, vs := range f.root.Cred.AuthHeaders() { + for _, v := range vs { + req.Header.Set(k, v) + } + } + res, err := f.root.client().Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("%w", ErrForbidden) + } + if res.StatusCode != http.StatusMultiStatus && res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("webdav PROPFIND list: %s", res.Status) + } + return parsePropfind(body, f.rel) +} + +type multiStatus struct { + Responses []propResponse `xml:"response"` +} + +type propResponse struct { + Href string `xml:"href"` + Propstat propStat `xml:"propstat"` +} + +type propStat struct { + Prop prop `xml:"prop"` +} + +type prop struct { + ResourceType resourceType `xml:"resourcetype"` +} + +type resourceType struct { + Collection *struct{} `xml:"collection"` +} + +func parsePropfind(body []byte, folderRel string) ([]Entry, error) { + var ms multiStatus + dec := xml.NewDecoder(bytes.NewReader(body)) + dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) { return input, nil } + if err := dec.Decode(&ms); err != nil { + return nil, fmt.Errorf("webdav propfind xml: %w", err) + } + folderBase := path.Base(folderRel) + var out []Entry + for _, resp := range ms.Responses { + href, err := url.PathUnescape(resp.Href) + if err != nil { + href = resp.Href + } + href = strings.TrimSuffix(href, "/") + name := path.Base(href) + if name == "" || name == folderBase || strings.HasSuffix(strings.TrimSuffix(href, "/"), "/"+folderRel) && name == path.Base(folderRel) { + if selfHref(href, folderRel) { + continue + } + } + if selfHref(href, folderRel) { + continue + } + isDir := resp.Propstat.Prop.ResourceType.Collection != nil + out = append(out, Entry{Name: name, IsDir: isDir}) + } + return out, nil +} + +func selfHref(href, folderRel string) bool { + h := strings.TrimSuffix(href, "/") + return strings.HasSuffix(h, "/"+folderRel) || strings.HasSuffix(h, folderRel) +} + +func (f webdavFolder) Read(name string) ([]byte, error) { + u, err := f.childURL(name) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, err + } + for k, vs := range f.root.Cred.AuthHeaders() { + for _, v := range vs { + req.Header.Set(k, v) + } + } + res, err := f.root.client().Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + switch res.StatusCode { + case http.StatusOK: + return body, nil + case http.StatusNotFound: + return nil, fmt.Errorf("%w: %s", ErrNotExist, name) + case http.StatusForbidden, http.StatusUnauthorized: + return nil, fmt.Errorf("%w", ErrForbidden) + default: + return nil, fmt.Errorf("webdav GET %s: %s", name, res.Status) + } +} + +func (f webdavFolder) Write(name string, data []byte) error { + u, err := f.childURL(name) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPut, u, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/octet-stream") + for k, vs := range f.root.Cred.AuthHeaders() { + for _, v := range vs { + req.Header.Set(k, v) + } + } + res, err := f.root.client().Do(req) + if err != nil { + return err + } + defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + if res.StatusCode == http.StatusCreated || res.StatusCode == http.StatusOK || res.StatusCode == http.StatusNoContent { + return nil + } + if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("%w", ErrForbidden) + } + return fmt.Errorf("webdav PUT %s: %s", name, res.Status) +} + +func (f webdavFolder) Exists(name string) (bool, error) { + u, err := f.childURL(name) + if err != nil { + return false, err + } + req, err := http.NewRequest(http.MethodHead, u, nil) + if err != nil { + return false, err + } + for k, vs := range f.root.Cred.AuthHeaders() { + for _, v := range vs { + req.Header.Set(k, v) + } + } + res, err := f.root.client().Do(req) + if err != nil { + return false, err + } + defer res.Body.Close() + _, _ = io.Copy(io.Discard, res.Body) + switch res.StatusCode { + case http.StatusOK: + return true, nil + case http.StatusNotFound: + return false, nil + case http.StatusForbidden, http.StatusUnauthorized: + return false, fmt.Errorf("%w", ErrForbidden) + default: + _, err := f.Read(name) + if err == nil { + return true, nil + } + if isNotExist(err) { + return false, nil + } + return false, err + } +} diff --git a/webdav_test.go b/webdav_test.go new file mode 100644 index 0000000..34a70de --- /dev/null +++ b/webdav_test.go @@ -0,0 +1,119 @@ +package goncfiles_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gonexapp "gitea.neitzel.de/konrad/go-nc-exapp" + "gitea.neitzel.de/konrad/go-nc-files" +) + +func TestWebDAVReadWriteListExists(t *testing.T) { + store := map[string][]byte{} + dirs := map[string]bool{"/remote.php/dav/files/alice/myapp": true} + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("AUTHORIZATION-APP-API") == "" { + http.Error(w, "no auth", 401) + return + } + path := r.URL.Path + switch r.Method { + case "PROPFIND": + if !dirs[strings.TrimSuffix(path, "/")] && store[path] == nil { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusMultiStatus) + var b strings.Builder + b.WriteString(``) + b.WriteString(`` + path + ``) + prefix := strings.TrimSuffix(path, "/") + for p := range store { + if strings.HasPrefix(p, prefix+"/") && !strings.Contains(strings.TrimPrefix(p, prefix+"/"), "/") { + b.WriteString(`` + p + ``) + } + } + b.WriteString(``) + _, _ = w.Write([]byte(b.String())) + case http.MethodPut: + data, _ := io.ReadAll(r.Body) + store[path] = data + w.WriteHeader(http.StatusCreated) + case http.MethodGet: + data, ok := store[path] + if !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write(data) + case http.MethodHead: + if _, ok := store[path]; !ok { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusOK) + case "MKCOL": + dirs[strings.TrimSuffix(path, "/")] = true + w.WriteHeader(http.StatusCreated) + default: + http.Error(w, "method", 405) + } + })) + t.Cleanup(srv.Close) + + cred := gonexapp.Credentials{ + BaseURL: srv.URL, + AppID: "myexapp", + AppVersion: "0.1.0", + AAVersion: "1.0.0", + AppSecret: "secret", + UserID: "alice", + } + root := goncfiles.WebDAVRoot{Cred: cred, Client: srv.Client()} + folder, err := root.EnsureFolder("myapp") + if err != nil { + t.Fatal(err) + } + if err := folder.Write("note.txt", []byte("hello")); err != nil { + t.Fatal(err) + } + ok, err := folder.Exists("note.txt") + if err != nil || !ok { + t.Fatalf("exists: ok=%v err=%v", ok, err) + } + data, err := folder.Read("note.txt") + if err != nil || string(data) != "hello" { + t.Fatalf("read: %q err=%v", data, err) + } + entries, err := folder.List() + if err != nil { + t.Fatal(err) + } + found := false + for _, e := range entries { + if e.Name == "note.txt" { + found = true + } + } + if !found { + t.Fatalf("list: %+v", entries) + } +} + +func TestWebDAVOpenMissingIsNotExist(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + root := goncfiles.WebDAVRoot{Cred: gonexapp.Credentials{ + BaseURL: srv.URL, AppID: "myexapp", AppVersion: "0.1.0", AAVersion: "1.0.0", AppSecret: "s", UserID: "alice", + }, Client: srv.Client()} + _, err := root.OpenFolder("nope") + if err == nil || !goncfiles.IsNotExist(err) { + t.Fatalf("got %v", err) + } +}