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