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 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) } // 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} } // List implements Folder for a local directory. 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 } // Read implements Folder for a local directory. 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)) } // Write implements Folder for a local directory. 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) } // Exists implements Folder for a local directory. 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 }