Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
710234fba1 | ||
|
|
300484114f |
+2
-2
@@ -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
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
|
||||
@@ -4,58 +4,44 @@ Shared Go library for Nextcloud ExApp Services: a User Files seam (local disk an
|
||||
|
||||
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.
|
||||
|
||||
## 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`
|
||||
- **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` / `OpenLocal` resolve 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`
|
||||
- [Files seam](docs/guide.md#files-seam) — `Root` and `Folder`
|
||||
- [Local](docs/guide.md#local) — filesystem-backed User Files Root
|
||||
- [WebDAVRoot](docs/guide.md#webdavroot) — Nextcloud Files over WebDAV
|
||||
- [CleanRel](docs/guide.md#cleanrel) — User Files Root–relative paths
|
||||
- [RequireBasename](docs/guide.md#requirebasename) — child file names
|
||||
- [DefaultPathStore](docs/guide.md#defaultpathstore) — Saved Default path
|
||||
- [Visit](docs/guide.md#visit) — Working Folder for one opening
|
||||
|
||||
**Excluded**
|
||||
## Excluded
|
||||
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests use a temporary local root (and httptest for WebDAV). No live Nextcloud is required for Library CI.
|
||||
Unit tests use a temporary local root and `httptest` for WebDAV. No live Nextcloud is required.
|
||||
|
||||
`go test ./...` fails when [`docs/api.md`](docs/api.md) does not match the exported API. Regenerate it with:
|
||||
|
||||
```bash
|
||||
UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1
|
||||
```
|
||||
|
||||
Runnable package examples: `go test -run Example`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
+278
@@ -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, ©Decl)
|
||||
}
|
||||
|
||||
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, ©Decl)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
+289
@@ -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
@@ -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
|
||||
```
|
||||
@@ -9,10 +9,14 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Sentinel errors for Visit / Working Folder resolution.
|
||||
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")
|
||||
)
|
||||
|
||||
@@ -32,6 +36,8 @@ type Folder interface {
|
||||
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
|
||||
}
|
||||
|
||||
// Root is User Files Root–relative folder access.
|
||||
@@ -48,6 +54,8 @@ type Local struct {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
dir, err := l.resolve(rel)
|
||||
if err != nil {
|
||||
@@ -69,7 +77,9 @@ func (l Local) OpenFolder(rel string) (Folder, error) {
|
||||
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) {
|
||||
dir, err := l.resolve(rel)
|
||||
if err != nil {
|
||||
@@ -92,8 +102,10 @@ func (l Local) resolve(rel string) (string, error) {
|
||||
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.
|
||||
// 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.
|
||||
func CleanRel(rel string) string {
|
||||
rel = strings.TrimSpace(rel)
|
||||
rel = strings.ReplaceAll(rel, "\\", "/")
|
||||
@@ -181,6 +193,18 @@ func (f localFolder) Exists(name string) (bool, error) {
|
||||
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.
|
||||
func RequireBasename(name string) error {
|
||||
if name == "" || name != path.Base(name) || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
)
|
||||
|
||||
// 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 {
|
||||
Cred gonexapp.Credentials
|
||||
Client *http.Client
|
||||
@@ -48,6 +50,10 @@ func (r WebDAVRoot) urlFor(rel string) (string, 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.
|
||||
func (r WebDAVRoot) OpenFolder(rel string) (Folder, error) {
|
||||
clean := CleanRel(rel)
|
||||
if clean == "" {
|
||||
@@ -63,7 +69,9 @@ func (r WebDAVRoot) OpenFolder(rel string) (Folder, error) {
|
||||
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) {
|
||||
clean := CleanRel(rel)
|
||||
if clean == "" {
|
||||
@@ -335,6 +343,41 @@ func (f webdavFolder) Write(name string, data []byte) error {
|
||||
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) {
|
||||
u, err := f.childURL(name)
|
||||
|
||||
@@ -56,6 +56,13 @@ func TestWebDAVReadWriteListExists(t *testing.T) {
|
||||
return
|
||||
}
|
||||
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":
|
||||
dirs[strings.TrimSuffix(path, "/")] = true
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
@@ -102,6 +109,13 @@ func TestWebDAVReadWriteListExists(t *testing.T) {
|
||||
if !found {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user