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 <cursoragent@cursor.com>
This commit is contained in:
Konrad Neitzel
2026-08-26 19:16:53 +02:00
co-authored by Cursor
commit 9068d8ba8e
14 changed files with 1308 additions and 0 deletions
+25
View File
@@ -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
+57
View File
@@ -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
+65
View File
@@ -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
}
+3
View File
@@ -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
+13
View File
@@ -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)
}
+191
View File
@@ -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
}
+100
View File
@@ -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)
}
}
}
+5
View File
@@ -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
+2
View File
@@ -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=
+93
View File
@@ -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
}
+137
View File
@@ -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)
}
}
+126
View File
@@ -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, `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>`+r.URL.Path+`</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response></d:multistatus>`)
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)
}
}
+372
View File
@@ -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(
`<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>`,
))
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(
`<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>`,
))
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
}
}
+119
View File
@@ -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(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">`)
b.WriteString(`<d:response><d:href>` + path + `</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>`)
prefix := strings.TrimSuffix(path, "/")
for p := range store {
if strings.HasPrefix(p, prefix+"/") && !strings.Contains(strings.TrimPrefix(p, prefix+"/"), "/") {
b.WriteString(`<d:response><d:href>` + p + `</d:href><d:propstat><d:prop><d:resourcetype/></d:prop></d:propstat></d:response>`)
}
}
b.WriteString(`</d:multistatus>`)
_, _ = 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)
}
}