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:
+25
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user