6 Commits
Author SHA1 Message Date
konradandCursor 710234fba1 Document every exported symbol and how callers use the library.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 19:25:44 +02:00
konradandCursor 300484114f Let a Working Folder delete a basename.
CheckDNS needs that to Remove Domain without a second storage path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 19:31:36 +02:00
konradandCursor 6560dffa69 Align the test path-store method.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 14:03:53 +02:00
Konrad NeitzelandCursor f0459bedfd Raise the module Go version to 1.27.0.
Match the Workspace pin so language and stdlib features through 1.27 are allowed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 17:16:17 +02:00
Konrad Neitzel 9935e5e7e2 Merge branch 'feature/go-library-docs' 2026-08-27 22:00:12 +02:00
Konrad NeitzelandCursor fa2f2e5f2c Document Folder APIs and add local Visit Examples.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 22:00:02 +02:00
12 changed files with 994 additions and 43 deletions
+2 -2
View File
@@ -9,7 +9,7 @@ The root of that Nextcloud user's files tree. Working Folder paths are relative
_Avoid_: absolute WebDAV URLs, numeric file IDs _Avoid_: absolute WebDAV URLs, numeric file IDs
**Working Folder**: **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. 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, writes, and removes there.
_Avoid_: catalog folder, zones directory, app-specific suffixes in Library code _Avoid_: catalog folder, zones directory, app-specific suffixes in Library code
**Saved Default**: **Saved Default**:
@@ -21,5 +21,5 @@ One inbound ExApp request bound to one Working Folder. App-icon launch uses the
_Avoid_: silently switching folders, creating a visit path that was not saved as default _Avoid_: silently switching folders, creating a visit path that was not saved as default
**Files seam**: **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. The `Root` and `Folder` interfaces: open or ensure a relative path, then list, read, write, remove, 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 _Avoid_: coupling the seam to a specific on-disk layout beyond basename rules
+24 -32
View File
@@ -2,54 +2,46 @@
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. 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` Import: `gitea.neitzel.de/konrad/go-nc-files` (package `goncfiles`).
```bash
go get 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. Depends on [go-nc-exapp](https://gitea.neitzel.de/konrad/go-nc-exapp) for AppAPI credentials on WebDAV.
## v1 scope - [Guide](docs/guide.md) — how to call each capability
- [API](docs/api.md) — every exported symbol
- [Domain language](CONTEXT.md)
**Included** ## Included
- **Files seam** — `Root` and `Folder` interfaces with `OpenFolder`, `EnsureFolder`, `List`, `Read`, `Write`, `Exists` - [Files seam](docs/guide.md#files-seam) — `Root` and `Folder`
- **Local** — filesystem-backed User Files Root for tests and local `go run` - [Local](docs/guide.md#local) — filesystem-backed User Files Root
- **WebDAVRoot** — Nextcloud Files at `/remote.php/dav/files/{user}/` using `gonexapp.Credentials` - [WebDAVRoot](docs/guide.md#webdavroot) — Nextcloud Files over WebDAV
- **CleanRel** — rejects `..`, URLs, and numeric file IDs - [CleanRel](docs/guide.md#cleanrel) — User Files Root–relative paths
- **RequireBasename** — shared basename rules for child file names - [RequireBasename](docs/guide.md#requirebasename) — child file names
- **DefaultPathStore** — `GetDefault` / `SetDefault` for Saved Default paths; `FileStore` (JSON) and `Memory` implementations - [DefaultPathStore](docs/guide.md#defaultpathstore) — Saved Default path
- **Visit** — `Open` resolves Working Folder from Saved Default (app-icon) or visit-only path (Files view); fail-closed on missing/forbidden visit paths - [Visit](docs/guide.md#visit) — Working Folder for one opening
- Sentinel errors: `ErrNotExist`, `ErrForbidden`, `ErrNotDir`
**Excluded from v1** ## Excluded
- ExApp lifecycle HTTP routes and HaRP bootstrap (see **go-nc-exapp**) - ExApp lifecycle HTTP routes and HaRP bootstrap (see **go-nc-exapp**)
- AppAPI preference OCS wiring (ExApp adapts `gonexapp.AppAPIPreferences` to `DefaultPathStore`) - AppAPI preference OCS wiring (the ExApp adapts `gonexapp.AppAPIPreferences` to `DefaultPathStore`)
- Catalog, DNS, or product-specific file semantics - Catalog, DNS, or product-specific file semantics
- Recursive tree walks and Nextcloud numeric file IDs - Recursive tree walks and Nextcloud numeric file IDs
## Usage ## Testing
```go Unit tests use a temporary local root and `httptest` for WebDAV. No live Nextcloud is required.
import (
goncfiles "gitea.neitzel.de/konrad/go-nc-files"
gonexapp "gitea.neitzel.de/konrad/go-nc-exapp"
)
// Local mode `go test ./...` fails when [`docs/api.md`](docs/api.md) does not match the exported API. Regenerate it with:
visit, err := goncfiles.OpenLocal("/data/files", "/data/settings.json", "myapp", "")
// WebDAV + ExApp preferences (adapter in your ExApp) ```bash
cred := gonexapp.Credentials{ /* ... */ } UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1
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. Runnable package examples: `go test -run Example`.
## Domain language
See [CONTEXT.md](./CONTEXT.md) for User Files Root, Working Folder, Saved Default, Visit, and Files seam terminology.
## Related ## Related
+278
View File
@@ -0,0 +1,278 @@
package goncfiles_test
import (
"bytes"
"fmt"
"go/ast"
"go/doc"
"go/doc/comment"
"go/parser"
"go/printer"
"go/token"
"os"
"strings"
"testing"
)
const apiModulePath = "gitea.neitzel.de/konrad/go-nc-files"
func TestAPIDoc(t *testing.T) {
got, err := renderAPIDoc(".", apiModulePath)
if err != nil {
t.Fatal(err)
}
const path = "docs/api.md"
if os.Getenv("UPDATE_API_DOCS") == "1" {
if err := os.MkdirAll("docs", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if string(want) != got {
t.Fatalf("docs/api.md is stale; regenerate with UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1\n%s", firstDiff(string(want), got))
}
}
func renderAPIDoc(dir, modulePath string) (string, error) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, parser.ParseComments)
if err != nil {
return "", err
}
if len(pkgs) != 1 {
return "", fmt.Errorf("expected 1 package in %s, found %d", dir, len(pkgs))
}
var astPkg *ast.Package
for _, p := range pkgs {
astPkg = p
}
pkg := doc.New(astPkg, modulePath, 0)
lookup := symLookup(pkg)
var b strings.Builder
b.WriteString("# API\n\n")
b.WriteString("Generated from the package comment and every exported declaration. Do not edit.\n")
b.WriteString("Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.\n\n")
fmt.Fprintf(&b, "## Package %s\n\n", pkg.Name)
writeDoc(&b, pkg.Doc, lookup)
writeValues(&b, fset, "Constants", pkg.Consts, "### ", lookup)
writeValues(&b, fset, "Variables", pkg.Vars, "### ", lookup)
writeFuncs(&b, fset, "Functions", pkg.Funcs, "### ", lookup)
if len(pkg.Types) > 0 {
b.WriteString("## Types\n\n")
for _, typ := range pkg.Types {
writeType(&b, fset, typ, lookup)
}
}
return b.String(), nil
}
func writeType(b *strings.Builder, fset *token.FileSet, typ *doc.Type, lookup func(string, string) bool) {
fmt.Fprintf(b, "### %s\n\n", typ.Name)
sig, err := formatGenDecl(fset, typ.Decl)
if err != nil {
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
} else {
writeCode(b, sig)
}
writeDoc(b, typ.Doc, lookup)
writeValues(b, fset, "", typ.Consts, "#### ", lookup)
writeValues(b, fset, "", typ.Vars, "#### ", lookup)
writeFuncs(b, fset, "", typ.Funcs, "#### ", lookup)
if !isInterface(typ) {
writeFuncs(b, fset, "", typ.Methods, "#### ", lookup)
}
}
func writeValues(b *strings.Builder, fset *token.FileSet, title string, vals []*doc.Value, heading string, lookup func(string, string) bool) {
if len(vals) == 0 {
return
}
if title != "" {
fmt.Fprintf(b, "## %s\n\n", title)
}
for _, v := range vals {
for _, name := range v.Names {
fmt.Fprintf(b, "%s%s\n\n", heading, name)
}
sig, err := formatGenDecl(fset, v.Decl)
if err != nil {
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
} else {
writeCode(b, sig)
}
writeDoc(b, v.Doc, lookup)
}
}
func writeFuncs(b *strings.Builder, fset *token.FileSet, title string, fns []*doc.Func, heading string, lookup func(string, string) bool) {
if len(fns) == 0 {
return
}
if title != "" {
fmt.Fprintf(b, "## %s\n\n", title)
}
for _, fn := range fns {
name := fn.Name
if fn.Recv != "" {
name = strings.TrimPrefix(fn.Recv, "*") + "." + fn.Name
}
fmt.Fprintf(b, "%s%s\n\n", heading, name)
sig, err := formatFunc(fset, fn.Decl)
if err != nil {
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
} else {
writeCode(b, sig)
}
writeDoc(b, fn.Doc, lookup)
}
}
func writeCode(b *strings.Builder, sig string) {
b.WriteString("```go\n")
b.WriteString(sig)
b.WriteString("\n```\n\n")
}
func writeDoc(b *strings.Builder, raw string, lookup func(string, string) bool) {
text := renderComment(raw, lookup)
if text == "" {
return
}
b.WriteString(text)
if !strings.HasSuffix(text, "\n") {
b.WriteByte('\n')
}
b.WriteByte('\n')
}
func symLookup(pkg *doc.Package) func(string, string) bool {
syms := map[string]bool{}
methods := map[string]bool{}
addNames := func(vals []*doc.Value) {
for _, v := range vals {
for _, name := range v.Names {
syms[name] = true
}
}
}
addFuncs := func(fns []*doc.Func) {
for _, fn := range fns {
syms[fn.Name] = true
if fn.Recv != "" {
recv := strings.TrimPrefix(fn.Recv, "*")
methods[recv+"."+fn.Name] = true
}
}
}
addNames(pkg.Consts)
addNames(pkg.Vars)
addFuncs(pkg.Funcs)
for _, typ := range pkg.Types {
syms[typ.Name] = true
addNames(typ.Consts)
addNames(typ.Vars)
addFuncs(typ.Funcs)
addFuncs(typ.Methods)
}
return func(recv, name string) bool {
if recv == "" {
return syms[name]
}
return methods[recv+"."+name]
}
}
func renderComment(raw string, lookup func(string, string) bool) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var parser comment.Parser
parser.LookupSym = lookup
var printer comment.Printer
printer.HeadingLevel = 4
printer.DocLinkURL = func(link *comment.DocLink) string {
if link.ImportPath != "" {
return link.DefaultURL("https://pkg.go.dev")
}
name := link.Name
if link.Recv != "" {
name = link.Recv + "." + link.Name
}
return "#" + githubAnchor(name)
}
return strings.TrimSpace(string(printer.Markdown(parser.Parse(raw))))
}
// githubAnchor matches GitHub heading slugs: lowercase, drop punctuation, keep
// letters, digits, hyphens, and underscores. "Client.Chat" becomes "clientchat".
func githubAnchor(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(name) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
}
}
return b.String()
}
func formatGenDecl(fset *token.FileSet, decl *ast.GenDecl) (string, error) {
if decl == nil {
return "", fmt.Errorf("nil decl")
}
copyDecl := *decl
copyDecl.Doc = nil
return formatNode(fset, &copyDecl)
}
func formatFunc(fset *token.FileSet, decl *ast.FuncDecl) (string, error) {
if decl == nil {
return "", fmt.Errorf("nil decl")
}
copyDecl := *decl
copyDecl.Doc = nil
copyDecl.Body = nil
return formatNode(fset, &copyDecl)
}
func formatNode(fset *token.FileSet, node ast.Node) (string, error) {
var buf bytes.Buffer
if err := printer.Fprint(&buf, fset, node); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
func isInterface(typ *doc.Type) bool {
if typ.Decl == nil || len(typ.Decl.Specs) != 1 {
return false
}
spec, ok := typ.Decl.Specs[0].(*ast.TypeSpec)
if !ok {
return false
}
_, ok = spec.Type.(*ast.InterfaceType)
return ok
}
func firstDiff(want, got string) string {
w := strings.Split(want, "\n")
g := strings.Split(got, "\n")
n := min(len(g), len(w))
for i := 0; i < n; i++ {
if w[i] != g[i] {
return fmt.Sprintf("line %d:\n got: %s\nwant: %s", i+1, g[i], w[i])
}
}
return fmt.Sprintf("length got %d lines, want %d lines", len(g), len(w))
}
+2
View File
@@ -9,7 +9,9 @@ import (
// DefaultPathStore persists the Saved Default Working Folder path (User Files Root–relative). // DefaultPathStore persists the Saved Default Working Folder path (User Files Root–relative).
type DefaultPathStore interface { type DefaultPathStore interface {
// GetDefault returns the stored path, or empty when none is set yet.
GetDefault() (string, error) GetDefault() (string, error)
// SetDefault writes a User Files Root–relative Working Folder path.
SetDefault(rel string) error SetDefault(rel string) error
} }
+289
View File
@@ -0,0 +1,289 @@
# API
Generated from the package comment and every exported declaration. Do not edit.
Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.
## Package goncfiles
Package goncfiles provides a Nextcloud User Files seam (local and WebDAV), Saved Default path storage, and Visit-scoped Working Folder resolution for ExApp Services.
## Variables
### ErrNotExist
### ErrForbidden
### ErrNotDir
```go
var (
// ErrNotExist means the folder or file is missing.
// Callers should use IsNotExist rather than comparing text.
ErrNotExist = errors.New("not found")
// ErrForbidden means the caller may not read or write the path.
// Callers should use IsForbidden rather than comparing text.
ErrForbidden = errors.New("forbidden")
// ErrNotDir means the path exists and is not a directory.
ErrNotDir = errors.New("not a directory")
)
```
## Functions
### CleanRel
```go
func CleanRel(rel string) string
```
CleanRel normalizes a User Files Root–relative path. The result is empty when rel is empty, contains "..", is a URL, or is a bare numeric Nextcloud file ID. An empty result is not a usable path. Backslashes are treated as slashes. A leading or trailing slash is removed.
### IsForbidden
```go
func IsForbidden(err error) bool
```
IsForbidden reports whether err wraps ErrForbidden.
### IsNotExist
```go
func IsNotExist(err error) bool
```
IsNotExist reports whether err wraps ErrNotExist.
### LocalPath
```go
func LocalPath(f Folder) (string, bool)
```
LocalPath returns the absolute directory when f is a local folder.
### RequireBasename
```go
func RequireBasename(name string) error
```
RequireBasename rejects path segments that are not a single basename.
## Types
### DefaultPathStore
```go
type DefaultPathStore interface {
// GetDefault returns the stored path, or empty when none is set yet.
GetDefault() (string, error)
// SetDefault writes a User Files Root–relative Working Folder path.
SetDefault(rel string) error
}
```
DefaultPathStore persists the Saved Default Working Folder path (User Files Root–relative).
### Entry
```go
type Entry struct {
Name string
IsDir bool
}
```
Entry is a first-level name in a Working Folder.
### FileStore
```go
type FileStore struct {
Path string
}
```
FileStore is the local-adapter settings JSON file.
#### FileStore.GetDefault
```go
func (s FileStore) GetDefault() (string, error)
```
GetDefault reads the saved default; empty means the caller should use the product initial default.
#### FileStore.SetDefault
```go
func (s FileStore) SetDefault(rel string) error
```
SetDefault writes the saved Working Folder path.
### Folder
```go
type Folder interface {
// List returns first-level names in the Working Folder.
List() ([]Entry, error)
// Read returns the contents of a basename in the Working Folder.
Read(name string) ([]byte, error)
// Write creates or replaces a basename in the Working Folder.
Write(name string, data []byte) error
// Exists reports whether name is a non-directory file in the Working Folder.
Exists(name string) (bool, error)
// Remove deletes a basename in the Working Folder.
Remove(name string) error
}
```
Folder is flat storage for one Working Folder (basenames only).
#### LocalFolder
```go
func LocalFolder(dir string) Folder
```
LocalFolder returns a Folder backed by an absolute local directory.
### Local
```go
type Local struct {
RootDir string
}
```
Local is a User Files Root on the local filesystem.
#### Local.EnsureFolder
```go
func (l Local) EnsureFolder(rel string) (Folder, error)
```
EnsureFolder creates the directory and its parents if missing, then opens it. It returns an error wrapping ErrForbidden when the process cannot create the directory, or a plain error when rel is not valid.
#### Local.OpenFolder
```go
func (l Local) OpenFolder(rel string) (Folder, error)
```
OpenFolder opens an existing directory under the local root. It returns an error wrapping ErrNotExist, ErrForbidden, or ErrNotDir, or a plain error when rel is not a valid User Files Root–relative path.
### Memory
```go
type Memory struct {
Value string
}
```
Memory is an in-memory DefaultPathStore for tests.
#### Memory.GetDefault
```go
func (m *Memory) GetDefault() (string, error)
```
GetDefault returns the in-memory value.
#### Memory.SetDefault
```go
func (m *Memory) SetDefault(rel string) error
```
SetDefault stores the value in memory.
### Root
```go
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)
}
```
Root is User Files Root–relative folder access.
### Visit
```go
type Visit struct {
SavedDefault string
WorkingRel string
VisitRelative string
FS Folder
// contains filtered or unexported fields
}
```
Visit is one ExApp opening: Working Folder for this request plus the Saved Default.
#### Open
```go
func Open(root Root, defaults DefaultPathStore, initialFolderName, visitRelative string) (*Visit, error)
```
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).
#### OpenLocal
```go
func OpenLocal(filesRoot, settingsPath, initialFolderName, visitRelative string) (*Visit, error)
```
OpenLocal is the local-adapter convenience: files root + settings JSON.
#### Visit.SetDefault
```go
func (v *Visit) SetDefault(relative string) error
```
SetDefault persists a new Saved Default and ensures the folder exists.
#### Visit.WorkingDir
```go
func (v *Visit) WorkingDir() string
```
WorkingDir returns a local absolute path when using the local adapter; otherwise WorkingRel.
### WebDAVRoot
```go
type WebDAVRoot struct {
Cred gonexapp.Credentials
Client *http.Client
}
```
WebDAVRoot is a User Files Root at /remote.php/dav/files/{user}/. Cred supplies the user id and AppAPI auth headers. A nil Client uses http.DefaultClient.
#### WebDAVRoot.EnsureFolder
```go
func (r WebDAVRoot) EnsureFolder(rel string) (Folder, error)
```
EnsureFolder creates the folder and missing parents with MKCOL, then opens it. It returns an error wrapping ErrForbidden on 401 or 403, or a plain error when rel is invalid or MKCOL fails for another status.
#### WebDAVRoot.OpenFolder
```go
func (r WebDAVRoot) OpenFolder(rel string) (Folder, error)
```
OpenFolder opens an existing folder via WebDAV PROPFIND. It does not create the folder. It returns an error wrapping ErrNotExist when the server responds 404, ErrForbidden on 401 or 403, and ErrNotDir when the resource is not a collection. An invalid rel returns a plain error and does not call the server.
+246
View File
@@ -0,0 +1,246 @@
# go-nc-files guide
How to use the library. Symbol signatures and doc comments are in [API](api.md). Words for the domain are in [CONTEXT.md](../CONTEXT.md).
Sentinel errors are `ErrNotExist`, `ErrForbidden`, and `ErrNotDir`. `IsNotExist` and `IsForbidden` report whether an error wraps the first two. The features below name which calls return them.
## Files seam
### Purpose
`Root` opens a folder by a User Files Root–relative path. `Folder` is flat storage inside that folder: list, read, write, exists, and remove, by basename only.
### When to use it
Write product code against `Root` and `Folder`. Pass a `Local` root in tests and a `WebDAVRoot` against Nextcloud. The rest of the call sequence stays the same.
### Call sequence
1. Obtain a `Root` (`Local` or `WebDAVRoot`).
2. `OpenFolder` when the folder must already exist. `EnsureFolder` when missing parents should be created.
3. On the `Folder`, call `List`, `Read`, `Write`, `Exists`, or `Remove` with a basename.
`List` returns first-level names. `Exists` reports a non-directory file. `Entry.IsDir` is true for a child directory.
### Errors
`OpenFolder` and `EnsureFolder` return a plain error when the relative path is invalid (`CleanRel` yields empty). A missing folder from `OpenFolder` wraps `ErrNotExist`. A path that exists and is not a directory wraps `ErrNotDir`. Permission failures wrap `ErrForbidden`.
`Read`, `Write`, `Exists`, and `Remove` return an error from `RequireBasename` when the name is not a single basename. `Remove` and WebDAV `Read` wrap `ErrNotExist` when the child is missing. `Exists` returns false and a nil error when the child is missing.
### Example
```go
folder, err := root.OpenFolder("Zones")
if err != nil {
return err
}
if err := folder.Write("note.txt", []byte("hi")); err != nil {
return err
}
data, err := folder.Read("note.txt")
```
## Local
### Purpose
`Local` is a User Files Root on the local filesystem. `RootDir` is the absolute directory that corresponds to the user's files root.
### When to use it
Use `Local` for tests and for `go run` without Nextcloud. Use `LocalFolder` when the caller already has an absolute directory and only needs a `Folder`. `LocalPath` reports that absolute directory when the `Folder` came from the local adapter.
### Call sequence
1. Set `Local.RootDir`.
2. Call `OpenFolder` or `EnsureFolder` with a relative path. `EnsureFolder` creates the directory with mode `0755`.
3. Or call `LocalFolder` with an absolute directory and skip the root.
`OpenLocal` is the Visit helper that pairs `Local` with a `FileStore`. It is described under Visit.
### Errors
`OpenFolder` wraps `ErrNotExist`, `ErrForbidden`, or `ErrNotDir`, or returns a plain error for an invalid relative path. `EnsureFolder` wraps `ErrForbidden` when the process cannot create the directory.
### Example
```go
root := goncfiles.Local{RootDir: filesRoot}
folder, err := root.EnsureFolder("myapp")
if err != nil {
return err
}
_ = folder
```
## WebDAVRoot
### Purpose
`WebDAVRoot` is a User Files Root at `/remote.php/dav/files/{user}/`. `Cred` supplies the user id and AppAPI auth headers from go-nc-exapp. A nil `Client` uses `http.DefaultClient`.
### When to use it
Use `WebDAVRoot` when the Working Folder is the user's Nextcloud files. The same `Root` and `Folder` methods apply as with `Local`.
### Call sequence
1. Fill `gonexapp.Credentials`, including `BaseURL` and `UserID`.
2. Store them on `WebDAVRoot.Cred`.
3. `OpenFolder` checks the folder with PROPFIND and does not create it.
4. `EnsureFolder` creates missing parents with MKCOL, then opens the folder.
5. `List` uses PROPFIND with depth 1. `Read` uses GET. `Write` uses PUT. `Remove` uses DELETE. `Exists` uses HEAD and falls back to GET when the status is neither success, not found, nor forbidden.
### Errors
An invalid relative path returns a plain error and does not call the server. `OpenFolder` wraps `ErrNotExist` on HTTP 404, `ErrForbidden` on 401 or 403, and `ErrNotDir` when the resource is not a collection. `EnsureFolder` wraps `ErrForbidden` on 401 or 403. Other unexpected statuses are plain errors that include the HTTP status.
### Example
```go
root := goncfiles.WebDAVRoot{Cred: cred}
folder, err := root.OpenFolder("Zones")
if err != nil {
if goncfiles.IsNotExist(err) {
return fmt.Errorf("folder missing")
}
return err
}
_ = folder
```
## CleanRel
### Purpose
`CleanRel` normalizes a User Files Root–relative path. The result uses forward slashes and has no leading or trailing slash.
### When to use it
The roots call `CleanRel` themselves. Call it directly when the product must reject a path before opening a folder, or must show the normalized form.
### Call sequence
1. Pass the raw path from a query parameter or a saved preference.
2. Treat an empty result as invalid. Do not open it.
Backslashes are treated as slashes. `..`, URLs, and a bare numeric Nextcloud file ID are rejected.
### Errors
`CleanRel` does not return an error. An empty string means the path is not usable. Callers that need an error, such as `OpenFolder`, turn that empty result into `invalid Working Folder path`.
### Example
```go
rel := goncfiles.CleanRel(r.URL.Query().Get("folder"))
if rel == "" {
http.Error(w, "invalid folder", http.StatusBadRequest)
return
}
```
## RequireBasename
### Purpose
`RequireBasename` accepts a single file name inside a Working Folder and rejects a path.
### When to use it
`Folder` methods call it before touching a child. Call it directly when the product checks a name before building a request.
### Call sequence
1. Pass the child name.
2. Continue only when the error is nil.
An empty name, a name containing `/` or `\`, a name containing `..`, or a name that is not `path.Base` of itself is rejected.
### Errors
The error text is `name must be a basename in the Working Folder`. It does not wrap `ErrNotExist` or `ErrForbidden`.
### Example
```go
if err := goncfiles.RequireBasename(name); err != nil {
return err
}
```
## DefaultPathStore
### Purpose
`DefaultPathStore` persists the Saved Default Working Folder path, relative to the User Files Root. `GetDefault` returns that path, or empty when none is stored. `SetDefault` writes a new path.
### When to use it
Visit reads and writes the Saved Default through this interface. `FileStore` is a JSON file (`defaultPath`) for the local adapter. `Memory` is an in-memory store for tests. An ExApp that stores the path in Nextcloud preferences implements the same two methods around `gonexapp.AppAPIPreferences`. This library does not call OCS.
### Call sequence
1. Construct a `FileStore` with a file path, a `Memory`, or a product store.
2. Pass it to `Open`.
3. `GetDefault` on a missing file returns empty and a nil error.
4. `SetDefault` writes the path. `FileStore` creates parent directories and writes indented JSON.
`Visit.SetDefault` is the call that also ensures the folder exists. It is described under Visit.
### Errors
`FileStore.GetDefault` returns an error when the file exists and cannot be read or is not JSON. A missing file is not an error. `FileStore.SetDefault` returns an error when the directory cannot be created or the file cannot be written. `Memory` does not return an error.
### Example
```go
store := goncfiles.FileStore{Path: filepath.Join(dir, "settings.json")}
saved, err := store.GetDefault()
if err != nil {
return err
}
_ = saved
```
## Visit
### Purpose
A Visit is one opening of the ExApp: the Working Folder for this request, and the Saved Default. App-icon launch uses the Saved Default and creates the initial folder when it is missing. A Files-view folder is used only for this visit and does not change the Saved Default.
### When to use it
Call `Open` at the start of a request that needs the user's Working Folder. Use `OpenLocal` when both the files root and the settings file are on local disk.
### Call sequence
1. Call `Open` with a `Root`, a `DefaultPathStore`, the product's initial folder name, and the visit-relative path.
2. Pass an empty visit-relative path for an app-icon launch. `Open` reads the Saved Default, uses `initialFolderName` when nothing is stored, and `EnsureFolder`s that path.
3. Pass the Files-view folder for a visit-only path. `Open` uses `OpenFolder` and does not create it and does not fall back to the Saved Default.
4. Use `Visit.FS` as the `Folder`, `WorkingRel` as the relative path, and `SavedDefault` as the stored default.
5. Call `SetDefault` when the user picks a new default. It `EnsureFolder`s the path, stores it, and updates `SavedDefault`. It does not switch `FS` to that folder.
6. `WorkingDir` returns a local absolute path when `FS` is a local folder, and `WorkingRel` otherwise.
### Errors
`Open` returns the error from `GetDefault`. An invalid saved path or an invalid visit path is a plain error (`invalid Working Folder path` or `invalid saved Working Folder path`). A missing or forbidden visit path fails closed: the error from `OpenFolder` is wrapped with the path, and the Saved Default is not opened instead.
`SetDefault` returns a plain error for an invalid path, or the error from `EnsureFolder` or `SetDefault` on the store.
### Example
```go
visit, err := goncfiles.Open(root, store, "myapp", visitRelative)
if err != nil {
if goncfiles.IsForbidden(err) {
http.Error(w, "forbidden", http.StatusForbidden)
return err
}
return err
}
data, err := visit.FS.Read("note.txt")
_ = visit.WorkingRel
_ = data
```
+51
View File
@@ -0,0 +1,51 @@
package goncfiles_test
import (
"fmt"
"os"
"path/filepath"
goncfiles "gitea.neitzel.de/konrad/go-nc-files"
)
func ExampleOpenLocal() {
root := tTempDir()
settings := filepath.Join(root, "settings.json")
files := filepath.Join(root, "files")
_ = os.MkdirAll(files, 0o755)
visit, err := goncfiles.OpenLocal(files, settings, "myapp", "")
if err != nil {
fmt.Println("err:", err)
return
}
if err := visit.FS.Write("note.txt", []byte("hi")); err != nil {
fmt.Println("write:", err)
return
}
data, err := visit.FS.Read("note.txt")
if err != nil {
fmt.Println("read:", err)
return
}
fmt.Println(visit.WorkingRel, string(data))
// Output: myapp hi
}
func ExampleLocalFolder() {
dir := tTempDir()
folder := goncfiles.LocalFolder(dir)
_ = folder.Write("a.txt", []byte("x"))
ok, _ := folder.Exists("a.txt")
fmt.Println(ok)
// Output: true
}
// tTempDir is a tiny helper so examples stay self-contained without testing.T.
func tTempDir() string {
dir, err := os.MkdirTemp("", "goncfiles-example-*")
if err != nil {
panic(err)
}
return dir
}
+36 -4
View File
@@ -9,10 +9,14 @@ import (
"strings" "strings"
) )
// Sentinel errors for Visit / Working Folder resolution.
var ( var (
// ErrNotExist means the folder or file is missing.
// Callers should use IsNotExist rather than comparing text.
ErrNotExist = errors.New("not found") ErrNotExist = errors.New("not found")
// ErrForbidden means the caller may not read or write the path.
// Callers should use IsForbidden rather than comparing text.
ErrForbidden = errors.New("forbidden") ErrForbidden = errors.New("forbidden")
// ErrNotDir means the path exists and is not a directory.
ErrNotDir = errors.New("not a directory") ErrNotDir = errors.New("not a directory")
) )
@@ -24,10 +28,16 @@ type Entry struct {
// Folder is flat storage for one Working Folder (basenames only). // Folder is flat storage for one Working Folder (basenames only).
type Folder interface { type Folder interface {
// List returns first-level names in the Working Folder.
List() ([]Entry, error) List() ([]Entry, error)
// Read returns the contents of a basename in the Working Folder.
Read(name string) ([]byte, error) Read(name string) ([]byte, error)
// Write creates or replaces a basename in the Working Folder.
Write(name string, data []byte) error Write(name string, data []byte) error
// Exists reports whether name is a non-directory file in the Working Folder.
Exists(name string) (bool, error) Exists(name string) (bool, error)
// Remove deletes a basename in the Working Folder.
Remove(name string) error
} }
// Root is User Files Root–relative folder access. // Root is User Files Root–relative folder access.
@@ -44,6 +54,8 @@ type Local struct {
} }
// OpenFolder opens an existing directory under the local root. // OpenFolder opens an existing directory under the local root.
// It returns an error wrapping ErrNotExist, ErrForbidden, or ErrNotDir,
// or a plain error when rel is not a valid User Files Root–relative path.
func (l Local) OpenFolder(rel string) (Folder, error) { func (l Local) OpenFolder(rel string) (Folder, error) {
dir, err := l.resolve(rel) dir, err := l.resolve(rel)
if err != nil { if err != nil {
@@ -65,7 +77,9 @@ func (l Local) OpenFolder(rel string) (Folder, error) {
return localFolder{dir: dir}, nil return localFolder{dir: dir}, nil
} }
// EnsureFolder creates the directory if missing, then opens it. // EnsureFolder creates the directory and its parents if missing, then opens it.
// It returns an error wrapping ErrForbidden when the process cannot create
// the directory, or a plain error when rel is not valid.
func (l Local) EnsureFolder(rel string) (Folder, error) { func (l Local) EnsureFolder(rel string) (Folder, error) {
dir, err := l.resolve(rel) dir, err := l.resolve(rel)
if err != nil { if err != nil {
@@ -88,8 +102,10 @@ func (l Local) resolve(rel string) (string, error) {
return filepath.Join(l.RootDir, filepath.FromSlash(clean)), nil return filepath.Join(l.RootDir, filepath.FromSlash(clean)), nil
} }
// CleanRel normalizes a User Files Root–relative path; empty means invalid. // CleanRel normalizes a User Files Root–relative path.
// Absolute URLs and numeric file IDs are rejected. // The result is empty when rel is empty, contains "..", is a URL, or is a
// bare numeric Nextcloud file ID. An empty result is not a usable path.
// Backslashes are treated as slashes. A leading or trailing slash is removed.
func CleanRel(rel string) string { func CleanRel(rel string) string {
rel = strings.TrimSpace(rel) rel = strings.TrimSpace(rel)
rel = strings.ReplaceAll(rel, "\\", "/") rel = strings.ReplaceAll(rel, "\\", "/")
@@ -133,6 +149,7 @@ func LocalFolder(dir string) Folder {
return localFolder{dir: dir} return localFolder{dir: dir}
} }
// List implements Folder for a local directory.
func (f localFolder) List() ([]Entry, error) { func (f localFolder) List() ([]Entry, error) {
entries, err := os.ReadDir(f.dir) entries, err := os.ReadDir(f.dir)
if err != nil { if err != nil {
@@ -145,6 +162,7 @@ func (f localFolder) List() ([]Entry, error) {
return out, nil return out, nil
} }
// Read implements Folder for a local directory.
func (f localFolder) Read(name string) ([]byte, error) { func (f localFolder) Read(name string) ([]byte, error) {
if err := RequireBasename(name); err != nil { if err := RequireBasename(name); err != nil {
return nil, err return nil, err
@@ -152,6 +170,7 @@ func (f localFolder) Read(name string) ([]byte, error) {
return os.ReadFile(filepath.Join(f.dir, name)) return os.ReadFile(filepath.Join(f.dir, name))
} }
// Write implements Folder for a local directory.
func (f localFolder) Write(name string, data []byte) error { func (f localFolder) Write(name string, data []byte) error {
if err := RequireBasename(name); err != nil { if err := RequireBasename(name); err != nil {
return err return err
@@ -159,6 +178,7 @@ func (f localFolder) Write(name string, data []byte) error {
return os.WriteFile(filepath.Join(f.dir, name), data, 0o644) return os.WriteFile(filepath.Join(f.dir, name), data, 0o644)
} }
// Exists implements Folder for a local directory.
func (f localFolder) Exists(name string) (bool, error) { func (f localFolder) Exists(name string) (bool, error) {
if err := RequireBasename(name); err != nil { if err := RequireBasename(name); err != nil {
return false, err return false, err
@@ -173,6 +193,18 @@ func (f localFolder) Exists(name string) (bool, error) {
return !info.IsDir(), nil return !info.IsDir(), nil
} }
// Remove implements Folder for a local directory.
func (f localFolder) Remove(name string) error {
if err := RequireBasename(name); err != nil {
return err
}
err := os.Remove(filepath.Join(f.dir, name))
if err != nil && os.IsNotExist(err) {
return fmt.Errorf("%w: %s", ErrNotExist, name)
}
return err
}
// RequireBasename rejects path segments that are not a single basename. // RequireBasename rejects path segments that are not a single basename.
func RequireBasename(name string) error { func RequireBasename(name string) error {
if name == "" || name != path.Base(name) || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { if name == "" || name != path.Base(name) || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
+1 -1
View File
@@ -1,5 +1,5 @@
module gitea.neitzel.de/konrad/go-nc-files module gitea.neitzel.de/konrad/go-nc-files
go 1.26.4 go 1.27.0
require gitea.neitzel.de/konrad/go-nc-exapp v0.1.0 require gitea.neitzel.de/konrad/go-nc-exapp v0.1.0
+48 -1
View File
@@ -14,6 +14,8 @@ import (
) )
// WebDAVRoot is a User Files Root at /remote.php/dav/files/{user}/. // WebDAVRoot is a User Files Root at /remote.php/dav/files/{user}/.
// Cred supplies the user id and AppAPI auth headers.
// A nil Client uses http.DefaultClient.
type WebDAVRoot struct { type WebDAVRoot struct {
Cred gonexapp.Credentials Cred gonexapp.Credentials
Client *http.Client Client *http.Client
@@ -48,6 +50,10 @@ func (r WebDAVRoot) urlFor(rel string) (string, error) {
} }
// OpenFolder opens an existing folder via WebDAV PROPFIND. // OpenFolder opens an existing folder via WebDAV PROPFIND.
// It does not create the folder.
// It returns an error wrapping ErrNotExist when the server responds 404,
// ErrForbidden on 401 or 403, and ErrNotDir when the resource is not a collection.
// An invalid rel returns a plain error and does not call the server.
func (r WebDAVRoot) OpenFolder(rel string) (Folder, error) { func (r WebDAVRoot) OpenFolder(rel string) (Folder, error) {
clean := CleanRel(rel) clean := CleanRel(rel)
if clean == "" { if clean == "" {
@@ -63,7 +69,9 @@ func (r WebDAVRoot) OpenFolder(rel string) (Folder, error) {
return webdavFolder{root: r, rel: clean}, nil return webdavFolder{root: r, rel: clean}, nil
} }
// EnsureFolder creates the folder (and parents) with MKCOL, then opens it. // EnsureFolder creates the folder and missing parents with MKCOL, then opens it.
// It returns an error wrapping ErrForbidden on 401 or 403, or a plain error
// when rel is invalid or MKCOL fails for another status.
func (r WebDAVRoot) EnsureFolder(rel string) (Folder, error) { func (r WebDAVRoot) EnsureFolder(rel string) (Folder, error) {
clean := CleanRel(rel) clean := CleanRel(rel)
if clean == "" { if clean == "" {
@@ -175,6 +183,7 @@ func (f webdavFolder) childURL(name string) (string, error) {
return f.root.urlFor(path.Join(f.rel, name)) return f.root.urlFor(path.Join(f.rel, name))
} }
// List implements Folder via WebDAV PROPFIND Depth 1.
func (f webdavFolder) List() ([]Entry, error) { func (f webdavFolder) List() ([]Entry, error) {
u, err := f.root.urlFor(f.rel) u, err := f.root.urlFor(f.rel)
if err != nil { if err != nil {
@@ -267,6 +276,7 @@ func selfHref(href, folderRel string) bool {
return strings.HasSuffix(h, "/"+folderRel) || strings.HasSuffix(h, folderRel) return strings.HasSuffix(h, "/"+folderRel) || strings.HasSuffix(h, folderRel)
} }
// Read implements Folder via WebDAV GET.
func (f webdavFolder) Read(name string) ([]byte, error) { func (f webdavFolder) Read(name string) ([]byte, error) {
u, err := f.childURL(name) u, err := f.childURL(name)
if err != nil { if err != nil {
@@ -302,6 +312,7 @@ func (f webdavFolder) Read(name string) ([]byte, error) {
} }
} }
// Write implements Folder via WebDAV PUT.
func (f webdavFolder) Write(name string, data []byte) error { func (f webdavFolder) Write(name string, data []byte) error {
u, err := f.childURL(name) u, err := f.childURL(name)
if err != nil { if err != nil {
@@ -332,6 +343,42 @@ func (f webdavFolder) Write(name string, data []byte) error {
return fmt.Errorf("webdav PUT %s: %s", name, res.Status) return fmt.Errorf("webdav PUT %s: %s", name, res.Status)
} }
// Remove implements Folder via WebDAV DELETE.
func (f webdavFolder) Remove(name string) error {
u, err := f.childURL(name)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodDelete, u, nil)
if err != nil {
return 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 err
}
defer res.Body.Close()
if _, err := io.Copy(io.Discard, res.Body); err != nil {
return err
}
switch res.StatusCode {
case http.StatusOK, http.StatusNoContent, http.StatusAccepted:
return nil
case http.StatusNotFound:
return fmt.Errorf("%w: %s", ErrNotExist, name)
case http.StatusForbidden, http.StatusUnauthorized:
return fmt.Errorf("%w", ErrForbidden)
default:
return fmt.Errorf("webdav DELETE %s: %s", name, res.Status)
}
}
// Exists implements Folder via WebDAV HEAD (falls back to GET when needed).
func (f webdavFolder) Exists(name string) (bool, error) { func (f webdavFolder) Exists(name string) (bool, error) {
u, err := f.childURL(name) u, err := f.childURL(name)
if err != nil { if err != nil {
+14
View File
@@ -56,6 +56,13 @@ func TestWebDAVReadWriteListExists(t *testing.T) {
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
case http.MethodDelete:
if _, ok := store[path]; !ok {
http.NotFound(w, r)
return
}
delete(store, path)
w.WriteHeader(http.StatusNoContent)
case "MKCOL": case "MKCOL":
dirs[strings.TrimSuffix(path, "/")] = true dirs[strings.TrimSuffix(path, "/")] = true
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
@@ -102,6 +109,13 @@ func TestWebDAVReadWriteListExists(t *testing.T) {
if !found { if !found {
t.Fatalf("list: %+v", entries) t.Fatalf("list: %+v", entries)
} }
if err := folder.Remove("note.txt"); err != nil {
t.Fatal(err)
}
ok, err = folder.Exists("note.txt")
if err != nil || ok {
t.Fatalf("after remove: ok=%v err=%v", ok, err)
}
} }
func TestWebDAVOpenMissingIsNotExist(t *testing.T) { func TestWebDAVOpenMissingIsNotExist(t *testing.T) {