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>
192 lines
4.5 KiB
Go
192 lines
4.5 KiB
Go
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
|
||
}
|