diff --git a/files_webdav.go b/files_webdav.go new file mode 100644 index 0000000..f3fcafd --- /dev/null +++ b/files_webdav.go @@ -0,0 +1,421 @@ +package onlyoffice + +// WebDAV-oriented Files operations. These expose the Documents module through +// value types and cover everything needed to back a filesystem mapping: +// listing (including the virtual @root sections), folder/file CRUD, move/copy, +// and streaming upload/download. They are intentionally small and dependency +// free (only net/http), so callers are not forced to import heavier parts of +// the library. + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// DavFolder is a folder row from the Files module. +type DavFolder struct { + ID string + Title string + ParentID string + RootType int // 1=Common, 3=Trash, 5=My, 6=Share, 8=Projects, ... + FilesCount int + FoldersCount int + Access int + Shared bool + Updated string +} + +// DavFile is a file row from the Files module. +type DavFile struct { + ID string + Title string + Size int64 + Updated string + ViewURL string +} + +// DavListing is the contents of one folder. +type DavListing struct { + Current DavFolder + Files []DavFile + Folders []DavFolder +} + +// ListDavFolder returns the contents of a folder by id, which may be a +// symbolic root such as "@my". For "@root" use ListDavSections. +func (c *Client) ListDavFolder(ctx context.Context, id string) (*DavListing, error) { + raw, err := c.getJSON(ctx, "/api/2.0/files/"+url.PathEscape(id)) + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + // @root returns an array with a single blob; a normal folder returns an + // object. Normalize both. + if len(resp) > 0 && resp[0] == '[' { + var arr []*DavListing + if err := json.Unmarshal(resp, &arr); err != nil { + return nil, err + } + if len(arr) == 0 { + return &DavListing{}, nil + } + return arr[0], nil + } + var l DavListing + if err := json.Unmarshal(resp, &l); err != nil { + return nil, err + } + return &l, nil +} + +// ListDavSections returns the virtual top-level sections shown by @root +// ("In projects", "My documents", "Shared with me", "Common", "Favorites", +// "Recent", "Trash"). Each is the `current` folder of one @root element. +func (c *Client) ListDavSections(ctx context.Context) ([]DavFolder, error) { + raw, err := c.getJSON(ctx, "/api/2.0/files/@root") + if err != nil { + return nil, err + } + resp, err := responseField(raw, "response") + if err != nil { + return nil, err + } + var arr []struct { + Current DavFolder `json:"current"` + } + if err := json.Unmarshal(resp, &arr); err != nil { + // Tolerate a non-array (single listing) response. + var single DavListing + if err2 := json.Unmarshal(resp, &single); err2 != nil { + return nil, err + } + return []DavFolder{single.Current}, nil + } + sections := make([]DavFolder, 0, len(arr)) + for i := range arr { + sections = append(sections, arr[i].Current) + } + return sections, nil +} + +// CreateDavFolder creates a folder titled title inside parentID. +func (c *Client) CreateDavFolder(ctx context.Context, parentID, title string) (*DavFolder, error) { + raw, err := c.postJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentID), + map[string]string{"title": title}) + if err != nil { + return nil, err + } + var env struct { + Response *DavFolder `json:"response"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return nil, err + } + if env.Response == nil { + return nil, fmt.Errorf("onlyoffice: empty create-folder response") + } + return env.Response, nil +} + +// RenameDavFolder renames a folder. +func (c *Client) RenameDavFolder(ctx context.Context, id, title string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id), + map[string]string{"title": title}) + return err +} + +// RenameDavFile renames a file (title includes the extension). +func (c *Client) RenameDavFile(ctx context.Context, id, title string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id), + map[string]string{"title": title}) + return err +} + +// MoveDavItems moves the given folders and/or files into destFolderID. +func (c *Client) MoveDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", map[string]any{ + "folderIds": nums(folderIDs), + "fileIds": nums(fileIDs), + "destFolderId": num(destFolderID), + "resolveType": "Skip", + "holdResult": true, + }) + return err +} + +// CopyDavItems copies the given folders and/or files into destFolderID. +func (c *Client) CopyDavItems(ctx context.Context, folderIDs, fileIDs []string, destFolderID string) error { + _, err := c.putJSON(ctx, "/api/2.0/files/fileops/copy", map[string]any{ + "folderIds": nums(folderIDs), + "fileIds": nums(fileIDs), + "destFolderId": num(destFolderID), + "conflictResolveType": "Skip", + "deleteAfter": true, + }) + return err +} + +// DeleteDavItems deletes the given folders and/or files. +func (c *Client) DeleteDavItems(ctx context.Context, folderIDs, fileIDs []string) error { + body := map[string]any{"DeleteAfter": true, "Immediately": false} + for _, id := range folderIDs { + if _, err := c.deleteJSON(ctx, "/api/2.0/files/folder/"+url.PathEscape(id), body); err != nil { + return err + } + } + for _, id := range fileIDs { + if _, err := c.deleteJSON(ctx, "/api/2.0/files/file/"+url.PathEscape(id), body); err != nil { + return err + } + } + return nil +} + +// UploadDavFile uploads src (fileName) into folderID, streaming from src. +func (c *Client) UploadDavFile(ctx context.Context, folderID, fileName string, src io.Reader) (*DavFile, error) { + raw, err := c.uploadReader(ctx, "/api/2.0/files/"+url.PathEscape(folderID)+"/upload", "file", fileName, src) + if err != nil { + return nil, err + } + var env struct { + Response *DavFile `json:"response"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return nil, err + } + if env.Response == nil { + return nil, fmt.Errorf("onlyoffice: empty upload response") + } + return env.Response, nil +} + +// DownloadDavFile streams the file identified by id to w, returning bytes copied. +func (c *Client) DownloadDavFile(ctx context.Context, id string, w io.Writer) (int64, error) { + file, err := c.GetFile(ctx, id) + if err != nil { + return 0, err + } + if file.ViewURL == nil || *file.ViewURL == "" { + return 0, fmt.Errorf("onlyoffice: file %s has no viewUrl", id) + } + u := c.resolveAPIURL(*file.ViewURL) + auth, err := c.authHeader() + if err != nil { + return 0, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", auth) + resp, err := c.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return 0, fmt.Errorf("onlyoffice: download: %d", resp.StatusCode) + } + return io.Copy(w, resp.Body) +} + +// --- internal helpers ------------------------------------------------------- + +// deleteJSON performs an authenticated DELETE with an optional JSON body. +func (c *Client) deleteJSON(ctx context.Context, path string, body any) (json.RawMessage, error) { + var reader io.Reader + if body != nil { + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(buf) + } + auth, err := c.authHeader() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL()+path, reader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("DELETE %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} + +// uploadReader uploads a stream to path under the given form field name. +func (c *Client) uploadReader(ctx context.Context, path, fieldName, fileName string, src io.Reader) (json.RawMessage, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile(fieldName, fileName) + if err != nil { + return nil, err + } + if _, err := io.Copy(part, src); err != nil { + return nil, err + } + if err := mw.Close(); err != nil { + return nil, err + } + auth, err := c.authHeader() + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL()+path, &buf) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Accept", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("upload %s: %d %s", path, resp.StatusCode, truncate(string(raw), 400)) + } + return raw, nil +} + +func nums(ids []string) []json.Number { + out := make([]json.Number, 0, len(ids)) + for _, id := range ids { + if _, err := strconv.Atoi(id); err == nil { + out = append(out, json.Number(id)) + } + } + return out +} + +func num(id string) any { + if _, err := strconv.Atoi(id); err == nil { + return json.Number(id) + } + return id +} + +// UnmarshalJSON decodes a folder from the portal envelope, including fields +// that the base FolderEntry omits (parentId, rootFolderType, access, ...). +func (f *DavFolder) UnmarshalJSON(b []byte) error { + var raw struct { + ID *json.Number `json:"id"` + Title *string `json:"title"` + ParentID *json.Number `json:"parentId"` + RootType *int `json:"rootFolderType"` + FilesCount *int `json:"filesCount"` + FoldersCount *int `json:"foldersCount"` + Access *int `json:"access"` + Shared *bool `json:"shared"` + Updated *string `json:"updated"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if raw.ID != nil { + f.ID = raw.ID.String() + } + if raw.Title != nil { + f.Title = *raw.Title + } + if raw.ParentID != nil { + f.ParentID = raw.ParentID.String() + } + if raw.RootType != nil { + f.RootType = *raw.RootType + } + if raw.FilesCount != nil { + f.FilesCount = *raw.FilesCount + } + if raw.FoldersCount != nil { + f.FoldersCount = *raw.FoldersCount + } + if raw.Access != nil { + f.Access = *raw.Access + } + if raw.Shared != nil { + f.Shared = *raw.Shared + } + if raw.Updated != nil { + f.Updated = *raw.Updated + } + return nil +} + +// UnmarshalJSON decodes a file row, capturing size and timestamps. +func (f *DavFile) UnmarshalJSON(b []byte) error { + var raw struct { + ID *json.Number `json:"id"` + Title *string `json:"title"` + PureSize *int64 `json:"pureContentLength"` + SizeStr *string `json:"contentLength"` + Updated *string `json:"updated"` + ViewURL *string `json:"viewUrl"` + } + if err := json.Unmarshal(b, &raw); err != nil { + return err + } + if raw.ID != nil { + f.ID = raw.ID.String() + } + if raw.Title != nil { + f.Title = *raw.Title + } + if raw.PureSize != nil { + f.Size = *raw.PureSize + } else if raw.SizeStr != nil { + if n, err := strconv.ParseInt(strings.Fields(*raw.SizeStr)[0], 10, 64); err == nil { + f.Size = n + } + } + if raw.Updated != nil { + f.Updated = *raw.Updated + } + if raw.ViewURL != nil { + f.ViewURL = *raw.ViewURL + } + return nil +} + +// ModTime parses the folder's updated timestamp. +func (f *DavFolder) ModTime() time.Time { + t, _ := time.Parse("2006-01-02T15:04:05.0000000-07:00", f.Updated) + return t +} + +// ModTime parses the file's updated timestamp. +func (f *DavFile) ModTime() time.Time { + t, _ := time.Parse("2006-01-02T15:04:05.0000000-07:00", f.Updated) + return t +}