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,372 @@
|
||||
package goncfiles
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
gonexapp "gitea.neitzel.de/konrad/go-nc-exapp"
|
||||
)
|
||||
|
||||
// WebDAVRoot is a User Files Root at /remote.php/dav/files/{user}/.
|
||||
type WebDAVRoot struct {
|
||||
Cred gonexapp.Credentials
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func (r WebDAVRoot) client() *http.Client {
|
||||
if r.Client != nil {
|
||||
return r.Client
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func (r WebDAVRoot) filesBase() string {
|
||||
return strings.TrimRight(r.Cred.BaseURL, "/") + "/remote.php/dav/files/" + url.PathEscape(r.Cred.UserID) + "/"
|
||||
}
|
||||
|
||||
func (r WebDAVRoot) urlFor(rel string) (string, error) {
|
||||
clean := CleanRel(rel)
|
||||
if clean == "" && rel != "" && rel != "." {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
base := r.filesBase()
|
||||
if clean == "" {
|
||||
return base, nil
|
||||
}
|
||||
parts := strings.Split(clean, "/")
|
||||
escaped := make([]string, len(parts))
|
||||
for i, p := range parts {
|
||||
escaped[i] = url.PathEscape(p)
|
||||
}
|
||||
return base + strings.Join(escaped, "/"), nil
|
||||
}
|
||||
|
||||
// OpenFolder opens an existing folder via WebDAV PROPFIND.
|
||||
func (r WebDAVRoot) OpenFolder(rel string) (Folder, error) {
|
||||
clean := CleanRel(rel)
|
||||
if clean == "" {
|
||||
return nil, fmt.Errorf("invalid Working Folder path")
|
||||
}
|
||||
u, err := r.urlFor(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.propfindExists(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return webdavFolder{root: r, rel: clean}, nil
|
||||
}
|
||||
|
||||
// EnsureFolder creates the folder (and parents) with MKCOL, then opens it.
|
||||
func (r WebDAVRoot) EnsureFolder(rel string) (Folder, error) {
|
||||
clean := CleanRel(rel)
|
||||
if clean == "" {
|
||||
return nil, fmt.Errorf("invalid Working Folder path")
|
||||
}
|
||||
parts := strings.Split(clean, "/")
|
||||
var built []string
|
||||
for _, p := range parts {
|
||||
built = append(built, p)
|
||||
cur := strings.Join(built, "/")
|
||||
u, err := r.urlFor(cur)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = r.propfindExists(u)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if !isNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.mkcol(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return webdavFolder{root: r, rel: clean}, nil
|
||||
}
|
||||
|
||||
func isNotExist(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), ErrNotExist.Error())
|
||||
}
|
||||
|
||||
func (r WebDAVRoot) propfindExists(u string) error {
|
||||
req, err := http.NewRequest("PROPFIND", u, strings.NewReader(
|
||||
`<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>`,
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Depth", "0")
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
for k, vs := range r.Cred.AuthHeaders() {
|
||||
for _, v := range vs {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
res, err := r.client().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch res.StatusCode {
|
||||
case http.StatusMultiStatus, http.StatusOK:
|
||||
if !propfindIsCollection(body) {
|
||||
return fmt.Errorf("%w", ErrNotDir)
|
||||
}
|
||||
return nil
|
||||
case http.StatusNotFound:
|
||||
return fmt.Errorf("%w", ErrNotExist)
|
||||
case http.StatusForbidden, http.StatusUnauthorized:
|
||||
return fmt.Errorf("%w", ErrForbidden)
|
||||
default:
|
||||
return fmt.Errorf("webdav PROPFIND %s: %s", u, res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func propfindIsCollection(body []byte) bool {
|
||||
return bytes.Contains(body, []byte("collection"))
|
||||
}
|
||||
|
||||
func (r WebDAVRoot) mkcol(u string) error {
|
||||
req, err := http.NewRequest("MKCOL", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, vs := range r.Cred.AuthHeaders() {
|
||||
for _, v := range vs {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
res, err := r.client().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
if res.StatusCode == http.StatusCreated || res.StatusCode == http.StatusMethodNotAllowed {
|
||||
return nil
|
||||
}
|
||||
if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("%w", ErrForbidden)
|
||||
}
|
||||
return fmt.Errorf("webdav MKCOL %s: %s", u, res.Status)
|
||||
}
|
||||
|
||||
type webdavFolder struct {
|
||||
root WebDAVRoot
|
||||
rel string
|
||||
}
|
||||
|
||||
func (f webdavFolder) childURL(name string) (string, error) {
|
||||
if err := RequireBasename(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return f.root.urlFor(path.Join(f.rel, name))
|
||||
}
|
||||
|
||||
func (f webdavFolder) List() ([]Entry, error) {
|
||||
u, err := f.root.urlFor(f.rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest("PROPFIND", u, strings.NewReader(
|
||||
`<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>`,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Depth", "1")
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
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 nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("%w", ErrForbidden)
|
||||
}
|
||||
if res.StatusCode != http.StatusMultiStatus && res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("webdav PROPFIND list: %s", res.Status)
|
||||
}
|
||||
return parsePropfind(body, f.rel)
|
||||
}
|
||||
|
||||
type multiStatus struct {
|
||||
Responses []propResponse `xml:"response"`
|
||||
}
|
||||
|
||||
type propResponse struct {
|
||||
Href string `xml:"href"`
|
||||
Propstat propStat `xml:"propstat"`
|
||||
}
|
||||
|
||||
type propStat struct {
|
||||
Prop prop `xml:"prop"`
|
||||
}
|
||||
|
||||
type prop struct {
|
||||
ResourceType resourceType `xml:"resourcetype"`
|
||||
}
|
||||
|
||||
type resourceType struct {
|
||||
Collection *struct{} `xml:"collection"`
|
||||
}
|
||||
|
||||
func parsePropfind(body []byte, folderRel string) ([]Entry, error) {
|
||||
var ms multiStatus
|
||||
dec := xml.NewDecoder(bytes.NewReader(body))
|
||||
dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) { return input, nil }
|
||||
if err := dec.Decode(&ms); err != nil {
|
||||
return nil, fmt.Errorf("webdav propfind xml: %w", err)
|
||||
}
|
||||
folderBase := path.Base(folderRel)
|
||||
var out []Entry
|
||||
for _, resp := range ms.Responses {
|
||||
href, err := url.PathUnescape(resp.Href)
|
||||
if err != nil {
|
||||
href = resp.Href
|
||||
}
|
||||
href = strings.TrimSuffix(href, "/")
|
||||
name := path.Base(href)
|
||||
if name == "" || name == folderBase || strings.HasSuffix(strings.TrimSuffix(href, "/"), "/"+folderRel) && name == path.Base(folderRel) {
|
||||
if selfHref(href, folderRel) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if selfHref(href, folderRel) {
|
||||
continue
|
||||
}
|
||||
isDir := resp.Propstat.Prop.ResourceType.Collection != nil
|
||||
out = append(out, Entry{Name: name, IsDir: isDir})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func selfHref(href, folderRel string) bool {
|
||||
h := strings.TrimSuffix(href, "/")
|
||||
return strings.HasSuffix(h, "/"+folderRel) || strings.HasSuffix(h, folderRel)
|
||||
}
|
||||
|
||||
func (f webdavFolder) Read(name string) ([]byte, error) {
|
||||
u, err := f.childURL(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, 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 nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch res.StatusCode {
|
||||
case http.StatusOK:
|
||||
return body, nil
|
||||
case http.StatusNotFound:
|
||||
return nil, fmt.Errorf("%w: %s", ErrNotExist, name)
|
||||
case http.StatusForbidden, http.StatusUnauthorized:
|
||||
return nil, fmt.Errorf("%w", ErrForbidden)
|
||||
default:
|
||||
return nil, fmt.Errorf("webdav GET %s: %s", name, res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func (f webdavFolder) Write(name string, data []byte) error {
|
||||
u, err := f.childURL(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPut, u, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
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()
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
if res.StatusCode == http.StatusCreated || res.StatusCode == http.StatusOK || res.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
if res.StatusCode == http.StatusForbidden || res.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("%w", ErrForbidden)
|
||||
}
|
||||
return fmt.Errorf("webdav PUT %s: %s", name, res.Status)
|
||||
}
|
||||
|
||||
func (f webdavFolder) Exists(name string) (bool, error) {
|
||||
u, err := f.childURL(name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodHead, u, nil)
|
||||
if err != nil {
|
||||
return false, 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 false, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
switch res.StatusCode {
|
||||
case http.StatusOK:
|
||||
return true, nil
|
||||
case http.StatusNotFound:
|
||||
return false, nil
|
||||
case http.StatusForbidden, http.StatusUnauthorized:
|
||||
return false, fmt.Errorf("%w", ErrForbidden)
|
||||
default:
|
||||
_, err := f.Read(name)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if isNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user