package nextcloud import ( "context" "encoding/json" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "sort" "strconv" "strings" "time" ) type FileInfo struct { Path string `json:"path"` Name string `json:"name"` Type string `json:"type"` // "file" or "directory" Size int64 `json:"size"` MimeType string `json:"mime_type"` LastModified string `json:"last_modified"` ETag string `json:"etag"` FileID int64 `json:"file_id,omitempty"` IsFavorite bool `json:"is_favorite"` IsShared bool `json:"is_shared"` Source string `json:"source,omitempty"` } type ShareInfo struct { ID string `json:"id"` Path string `json:"path"` ShareType int `json:"share_type"` Permissions int `json:"permissions"` URL string `json:"url"` InternalURL string `json:"internal_url,omitempty"` AccessMode string `json:"access_mode,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` CreatedAt string `json:"created_at,omitempty"` ShareWith string `json:"share_with,omitempty"` ShareWithDisplayName string `json:"share_with_displayname,omitempty"` OwnerID string `json:"owner_id,omitempty"` OwnerDisplayName string `json:"owner_displayname,omitempty"` FileOwnerID string `json:"file_owner_id,omitempty"` FileOwnerDisplayName string `json:"file_owner_displayname,omitempty"` Note string `json:"note,omitempty"` ItemType string `json:"item_type,omitempty"` HasPassword bool `json:"has_password,omitempty"` Label string `json:"label,omitempty"` Token string `json:"token,omitempty"` } // CreateShareOptions holds optional OCS share creation parameters. type CreateShareOptions struct { ShareType int Permissions int ShareWith string Password string ExpireDate string Note string Label string Attributes string SendMail bool AccessMode string } type HTTPStatusError struct { Operation string StatusCode int } func (e *HTTPStatusError) Error() string { return fmt.Sprintf("%s failed: %d", e.Operation, e.StatusCode) } type UserQuota struct { Used int64 `json:"used"` Free int64 `json:"free"` Total int64 `json:"total"` Relative int64 `json:"relative"` } func (c *Client) ListFiles(ctx context.Context, userID, path string) ([]FileInfo, error) { davPath := c.WebDAVPath(userID, path) body := ` ` resp, err := c.DoAsUser(ctx, "PROPFIND", davPath, strings.NewReader(body), userID, map[string]string{ "Depth": "1", "Content-Type": "application/xml", }) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 207 { return nil, &HTTPStatusError{Operation: "propfind", StatusCode: resp.StatusCode} } return parsePropfindResponse(resp.Body, path) } func (c *Client) Upload(ctx context.Context, userID, path string, content io.Reader, contentType string) error { davPath := c.WebDAVPath(userID, path) headers := map[string]string{} if contentType != "" { headers["Content-Type"] = contentType } resp, err := c.DoAsUser(ctx, "PUT", davPath, content, userID, headers) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 && resp.StatusCode != 204 { return &HTTPStatusError{Operation: "upload", StatusCode: resp.StatusCode} } return nil } func (c *Client) Download(ctx context.Context, userID, path string) (io.ReadCloser, string, error) { path = NormalizeClientFilePath(userID, path) davPath := c.WebDAVPath(userID, path) resp, err := c.DoAsUser(ctx, "GET", davPath, nil, userID, nil) if err != nil { return nil, "", err } if resp.StatusCode != 200 { resp.Body.Close() return nil, "", &HTTPStatusError{Operation: "download", StatusCode: resp.StatusCode} } return resp.Body, resp.Header.Get("Content-Type"), nil } func (c *Client) CreateFolder(ctx context.Context, userID, path string) error { davPath := c.WebDAVPath(userID, path) resp, err := c.DoAsUser(ctx, "MKCOL", davPath, nil, userID, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 { return &HTTPStatusError{Operation: "mkcol", StatusCode: resp.StatusCode} } return nil } func (c *Client) Delete(ctx context.Context, userID, path string) error { davPath := c.WebDAVPath(userID, path) resp, err := c.DoAsUser(ctx, "DELETE", davPath, nil, userID, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 204 { return &HTTPStatusError{Operation: "delete", StatusCode: resp.StatusCode} } return nil } func normalizeOperationPath(userID, p string) string { return NormalizeClientFilePath(userID, NormalizeClientPath(p)) } func (c *Client) Move(ctx context.Context, userID, srcPath, destPath string) error { srcPath = normalizeOperationPath(userID, srcPath) destPath = normalizeOperationPath(userID, destPath) davSrc := c.WebDAVPath(userID, srcPath) destHeader := c.webDAVDestination(c.WebDAVPath(userID, destPath)) resp, err := c.DoAsUser(ctx, "MOVE", davSrc, nil, userID, map[string]string{ "Destination": destHeader, "Overwrite": "F", }) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 201 && resp.StatusCode != 204 { return &HTTPStatusError{Operation: "move", StatusCode: resp.StatusCode} } return nil } func (c *Client) Copy(ctx context.Context, userID, srcPath, destPath string) error { srcPath = normalizeOperationPath(userID, srcPath) destPath = normalizeOperationPath(userID, destPath) davSrc := c.WebDAVPath(userID, srcPath) destHeader := c.webDAVDestination(c.WebDAVPath(userID, destPath)) resp, err := c.DoAsUser(ctx, "COPY", davSrc, nil, userID, map[string]string{ "Destination": destHeader, "Overwrite": "F", }) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { return &HTTPStatusError{Operation: "copy", StatusCode: resp.StatusCode} } return nil } func (c *Client) UploadChunk(ctx context.Context, userID, uploadID, chunkName string, content io.Reader, contentType string) error { uploadPath := fmt.Sprintf("/remote.php/dav/uploads/%s/%s/%s", userID, uploadID, chunkName) headers := map[string]string{} if contentType != "" { headers["Content-Type"] = contentType } resp, err := c.DoAsUser(ctx, "PUT", uploadPath, content, userID, headers) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { return &HTTPStatusError{Operation: "upload chunk", StatusCode: resp.StatusCode} } return nil } func (c *Client) AssembleChunks(ctx context.Context, userID, uploadID, destinationPath string, totalSize int64) error { source := fmt.Sprintf("/remote.php/dav/uploads/%s/%s/.file", userID, uploadID) destination := c.webDAVDestination(c.WebDAVPath(userID, destinationPath)) headers := map[string]string{ "Destination": destination, } if totalSize > 0 { headers["OC-Total-Length"] = strconv.FormatInt(totalSize, 10) } resp, err := c.DoAsUser(ctx, "MOVE", source, nil, userID, headers) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { return &HTTPStatusError{Operation: "assemble chunks", StatusCode: resp.StatusCode} } return nil } func (c *Client) AbortChunkUpload(ctx context.Context, userID, uploadID string) error { uploadPath := fmt.Sprintf("/remote.php/dav/uploads/%s/%s", userID, uploadID) resp, err := c.DoAsUser(ctx, "DELETE", uploadPath, nil, userID, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound { return &HTTPStatusError{Operation: "abort upload", StatusCode: resp.StatusCode} } return nil } func (c *Client) ListTrash(ctx context.Context, userID string) ([]FileInfo, error) { basePath := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash", url.PathEscape(strings.TrimSpace(userID))) body := ` ` resp, err := c.DoAsUser(ctx, "PROPFIND", basePath, strings.NewReader(body), userID, map[string]string{ "Depth": "1", "Content-Type": "application/xml", }) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != 207 { return nil, &HTTPStatusError{Operation: "list trash", StatusCode: resp.StatusCode} } return parsePropfindResponse(resp.Body, "/") } func (c *Client) ListRecent(ctx context.Context, userID string, limit int) ([]FileInfo, error) { files, err := c.listRecentOCS(ctx, userID, limit) if err == nil { return files, nil } var statusErr *HTTPStatusError if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) { return c.listRecentFromRoot(ctx, userID, limit) } return nil, err } func (c *Client) listRecentOCS(ctx context.Context, userID string, limit int) ([]FileInfo, error) { path := "/ocs/v2.php/apps/files/api/v1/recent?format=json" if limit > 0 { path = fmt.Sprintf("%s&limit=%d", path, limit) } resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, ocsJSONHeaders()) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, &HTTPStatusError{Operation: "list recent", StatusCode: resp.StatusCode} } var payload struct { OCS struct { Data []struct { Path string `json:"path"` Name string `json:"name"` MimeType string `json:"mimetype"` ETag string `json:"etag"` Type string `json:"type"` Size int64 `json:"size"` MTime any `json:"mtime"` } `json:"data"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, err } files := make([]FileInfo, 0, len(payload.OCS.Data)) for _, item := range payload.OCS.Data { fileType := "file" if strings.EqualFold(item.Type, "dir") || strings.EqualFold(item.Type, "directory") { fileType = "directory" } lastModified := "" if ts := parseAnyInt64(item.MTime); ts > 0 { lastModified = time.Unix(ts, 0).UTC().Format(time.RFC3339) } logicalPath := EnsureClientFilePath( NormalizeClientFilePath(userID, item.Path), item.Name, ) name := SyncFileDisplayName(logicalPath, item.Name) files = append(files, FileInfo{ Path: logicalPath, Name: name, Type: fileType, Size: item.Size, MimeType: item.MimeType, LastModified: lastModified, ETag: strings.Trim(item.ETag, "\""), }) } return files, nil } // listRecentFromRoot approximates recents when the Files app recent API is unavailable. func (c *Client) listRecentFromRoot(ctx context.Context, userID string, limit int) ([]FileInfo, error) { if limit <= 0 { limit = 50 } all, err := c.ListFiles(ctx, userID, "/") if err != nil { return nil, err } files := make([]FileInfo, 0, len(all)) for _, f := range all { if f.Type == "file" { files = append(files, f) } } sort.Slice(files, func(i, j int) bool { return fileModifiedTime(files[i].LastModified).After(fileModifiedTime(files[j].LastModified)) }) if len(files) > limit { files = files[:limit] } return files, nil } func (c *Client) ListSharedWithMe(ctx context.Context, userID string) ([]FileInfo, error) { path := "/ocs/v2.php/apps/files_sharing/api/v1/shares?shared_with_me=true&format=json" resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, ocsJSONHeaders()) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, &HTTPStatusError{Operation: "list shared with me", StatusCode: resp.StatusCode} } var payload struct { OCS struct { Data []struct { Path string `json:"path"` Name string `json:"name"` ItemType string `json:"item_type"` MimeType string `json:"mimetype"` ETag string `json:"etag"` Size int64 `json:"storage"` MTime any `json:"mtime"` } `json:"data"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return nil, err } files := make([]FileInfo, 0, len(payload.OCS.Data)) for _, item := range payload.OCS.Data { name := strings.TrimSpace(item.Name) if name == "" { name = pathBaseName(item.Path) } fileType := "file" if strings.EqualFold(item.ItemType, "folder") || strings.EqualFold(item.ItemType, "dir") || strings.EqualFold(item.ItemType, "directory") || strings.HasPrefix(item.MimeType, "httpd/unix-directory") { fileType = "directory" } lastModified := "" if ts := parseAnyInt64(item.MTime); ts > 0 { lastModified = time.Unix(ts, 0).UTC().Format(time.RFC3339) } logicalPath := EnsureClientFilePath( NormalizeClientFilePath(userID, item.Path), name, ) name = SyncFileDisplayName(logicalPath, name) files = append(files, FileInfo{ Path: logicalPath, Name: name, Type: fileType, Size: item.Size, MimeType: item.MimeType, LastModified: lastModified, ETag: strings.Trim(item.ETag, "\""), IsShared: true, }) } return files, nil } func pathBaseName(raw string) string { raw = strings.TrimSpace(raw) raw = strings.Trim(raw, "/") if raw == "" { return "" } if idx := strings.LastIndex(raw, "/"); idx >= 0 { return raw[idx+1:] } return raw } func (c *Client) ListShares(ctx context.Context, userID, filePath string) ([]ShareInfo, error) { path := "/ocs/v2.php/apps/files_sharing/api/v1/shares?path=" + url.QueryEscape(filePath) resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, map[string]string{ "Accept": "application/json", }) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, &HTTPStatusError{Operation: "list shares", StatusCode: resp.StatusCode} } var ocsResp struct { OCS struct { Data json.RawMessage `json:"data"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil { return nil, err } items, err := decodeOCSShareRecords(ocsResp.OCS.Data) if err != nil { return nil, err } out := make([]ShareInfo, 0, len(items)) for _, item := range items { out = append(out, mapOCSShareRecord(item, filePath)) } return out, nil } func (c *Client) UpdateShare(ctx context.Context, userID, shareID string, permissions int, expireDate, password string) (*ShareInfo, error) { form := fmt.Sprintf("permissions=%d", permissions) if expireDate != "" { form += "&expireDate=" + expireDate } if password != "" { form += "&password=" + password } apiPath := fmt.Sprintf("/ocs/v2.php/apps/files_sharing/api/v1/shares/%s", shareID) resp, err := c.DoAsUser(ctx, "PUT", apiPath, strings.NewReader(form), userID, map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", }) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, &HTTPStatusError{Operation: "update share", StatusCode: resp.StatusCode} } return decodeShareResponse(resp.Body, "") } func (c *Client) DeleteShare(ctx context.Context, userID, shareID string) error { apiPath := fmt.Sprintf("/ocs/v2.php/apps/files_sharing/api/v1/shares/%s", shareID) resp, err := c.DoAsUser(ctx, "DELETE", apiPath, nil, userID, map[string]string{ "Accept": "application/json", }) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { return &HTTPStatusError{Operation: "delete share", StatusCode: resp.StatusCode} } return nil } func trashbinItemSeg(trashName string) string { logical := strings.Trim(strings.TrimPrefix(trashName, "/"), "/") if logical == "" { return "" } parts := strings.Split(logical, "/") for i, p := range parts { parts[i] = url.PathEscape(p) } return strings.Join(parts, "/") } func (c *Client) RestoreFromTrash(ctx context.Context, userID, trashName string) error { userSeg := url.PathEscape(strings.TrimSpace(userID)) nameSeg := trashbinItemSeg(trashName) src := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash/%s", userSeg, nameSeg) destHeader := c.webDAVDestination(fmt.Sprintf("/remote.php/dav/trashbin/%s/restore/%s", userSeg, nameSeg)) resp, err := c.DoAsUser(ctx, "MOVE", src, nil, userID, map[string]string{ "Destination": destHeader, "Overwrite": "T", }) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { return &HTTPStatusError{Operation: "restore trash", StatusCode: resp.StatusCode} } return nil } func (c *Client) DeleteFromTrash(ctx context.Context, userID, trashName string) error { userSeg := url.PathEscape(strings.TrimSpace(userID)) nameSeg := trashbinItemSeg(trashName) apiPath := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash/%s", userSeg, nameSeg) resp, err := c.DoAsUser(ctx, "DELETE", apiPath, nil, userID, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return &HTTPStatusError{Operation: "delete trash", StatusCode: resp.StatusCode} } return nil } func (c *Client) EmptyTrash(ctx context.Context, userID string) error { userSeg := url.PathEscape(strings.TrimSpace(userID)) apiPath := fmt.Sprintf("/remote.php/dav/trashbin/%s/trash", userSeg) resp, err := c.DoAsUser(ctx, "DELETE", apiPath, nil, userID, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return &HTTPStatusError{Operation: "empty trash", StatusCode: resp.StatusCode} } return nil } const ( favoritesMaxDirs = 2000 favoritesMaxCollect = 500 filterCorpusMaxDirs = 2000 filterCorpusMaxFiles = 10000 ) func (c *Client) ListFavorites(ctx context.Context, userID, basePath string, maxCollect int) ([]FileInfo, error) { if maxCollect <= 0 { maxCollect = favoritesMaxCollect } basePath = normalizeSearchPath(basePath) if basePath == "" { basePath = "/" } queue := []string{basePath} seen := map[string]struct{}{basePath: {}} results := make([]FileInfo, 0, maxCollect) visited := 0 for len(queue) > 0 && visited < favoritesMaxDirs && len(results) < maxCollect { dir := queue[0] queue = queue[1:] visited++ files, err := c.ListFiles(ctx, userID, dir) if err != nil { continue } for _, f := range files { if f.IsFavorite { results = append(results, f) if len(results) >= maxCollect { break } } if !isDirectoryEntry(f) { continue } child := normalizeSearchPath(f.Path) if child == "" || child == "/" { continue } if _, ok := seen[child]; ok { continue } seen[child] = struct{}{} queue = append(queue, child) } } return results, nil } // ListFilesRecursive collects file entries (not directories) under basePath for client-side filtering. func (c *Client) ListFilesRecursive(ctx context.Context, userID, basePath string, maxFiles int) ([]FileInfo, error) { if maxFiles <= 0 { maxFiles = filterCorpusMaxFiles } basePath = normalizeSearchPath(basePath) if basePath == "" { basePath = "/" } queue := []string{basePath} seen := map[string]struct{}{basePath: {}} results := make([]FileInfo, 0, min(maxFiles, 256)) visited := 0 for len(queue) > 0 && visited < filterCorpusMaxDirs && len(results) < maxFiles { dir := queue[0] queue = queue[1:] visited++ files, err := c.ListFiles(ctx, userID, dir) if err != nil { continue } for _, f := range files { if isDirectoryEntry(f) { child := normalizeSearchPath(f.Path) if child == "" || child == "/" { continue } if _, ok := seen[child]; ok { continue } seen[child] = struct{}{} queue = append(queue, child) continue } results = append(results, f) if len(results) >= maxFiles { break } } } return results, nil } func (c *Client) SetFavorite(ctx context.Context, userID, filePath string, favorite bool) error { filePath = normalizeOperationPath(userID, filePath) davPath := c.WebDAVPath(userID, filePath) val := "0" if favorite { val = "1" } body := fmt.Sprintf(` %s `, val) resp, err := c.DoAsUser(ctx, "PROPPATCH", davPath, strings.NewReader(body), userID, map[string]string{ "Content-Type": "application/xml", }) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { return &HTTPStatusError{Operation: "set favorite", StatusCode: resp.StatusCode} } return nil } func decodeShareResponse(body io.Reader, path string) (*ShareInfo, error) { return decodeOCSShareResponse(body, path) } func (c *Client) CreateShare(ctx context.Context, userID, path string, opts CreateShareOptions) (*ShareInfo, error) { form := url.Values{} form.Set("path", path) form.Set("shareType", strconv.Itoa(opts.ShareType)) form.Set("permissions", strconv.Itoa(opts.Permissions)) if shareWith := strings.TrimSpace(opts.ShareWith); shareWith != "" { form.Set("shareWith", shareWith) } if password := strings.TrimSpace(opts.Password); password != "" { form.Set("password", password) } if expireDate := strings.TrimSpace(opts.ExpireDate); expireDate != "" { form.Set("expireDate", expireDate) } if note := strings.TrimSpace(opts.Note); note != "" { form.Set("note", note) } if label := strings.TrimSpace(opts.Label); label != "" { form.Set("label", label) } if attributes := strings.TrimSpace(opts.Attributes); attributes != "" { form.Set("attributes", attributes) } if opts.SendMail { form.Set("sendMail", "true") } resp, err := c.DoAsUser(ctx, "POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", strings.NewReader(form.Encode()), userID, map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", }) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, &HTTPStatusError{Operation: "create share", StatusCode: resp.StatusCode} } share, err := decodeShareResponse(resp.Body, path) if err != nil { return nil, err } if opts.AccessMode != "" { share.AccessMode = opts.AccessMode } else if share.AccessMode == "" && opts.ShareType == 3 { share.AccessMode = "public" } if opts.ShareType == 4 { share.AccessMode = "email" } if opts.ShareType == 0 { share.AccessMode = "user" } return share, nil } func (c *Client) SendShareEmail(ctx context.Context, userID, shareID, password string) error { form := url.Values{} if password := strings.TrimSpace(password); password != "" { form.Set("password", password) } apiPath := fmt.Sprintf("/ocs/v2.php/apps/files_sharing/api/v1/shares/%s/send-email", url.PathEscape(shareID)) resp, err := c.DoAsUser(ctx, "POST", apiPath, strings.NewReader(form.Encode()), userID, map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", }) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return &HTTPStatusError{Operation: "send share email", StatusCode: resp.StatusCode} } return nil } func (c *Client) GetQuota(ctx context.Context, userID string) (UserQuota, error) { var lastErr error if q, err := c.getQuotaOCSCurrentUser(ctx, userID); err == nil { return q, nil } else { lastErr = err } if q, err := c.getQuotaOCSUserRecord(ctx, userID); err == nil { return q, nil } else { lastErr = err } if q, err := c.getQuotaWebDAV(ctx, userID); err == nil { return q, nil } else { lastErr = err } return UserQuota{}, lastErr } func (c *Client) getQuotaOCSCurrentUser(ctx context.Context, userID string) (UserQuota, error) { resp, err := c.DoAsUser(ctx, "GET", "/ocs/v2.php/cloud/user?format=json", nil, userID, ocsJSONHeaders()) if err != nil { return UserQuota{}, err } defer resp.Body.Close() return decodeOCSQuotaResponse(resp, "get quota (cloud/user)") } func (c *Client) getQuotaOCSUserRecord(ctx context.Context, userID string) (UserQuota, error) { path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", url.PathEscape(userID)) resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, ocsJSONHeaders()) if err != nil { return UserQuota{}, err } defer resp.Body.Close() return decodeOCSQuotaResponse(resp, "get quota (cloud/users)") } func (c *Client) getQuotaWebDAV(ctx context.Context, userID string) (UserQuota, error) { davPath := c.WebDAVPath(userID, "") body := ` ` resp, err := c.DoAsUser(ctx, "PROPFIND", davPath, strings.NewReader(body), userID, map[string]string{ "Depth": "0", "Content-Type": "application/xml", }) if err != nil { return UserQuota{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { return UserQuota{}, &HTTPStatusError{Operation: "get quota (webdav)", StatusCode: resp.StatusCode} } return parseQuotaPropfind(resp.Body) } func decodeOCSQuotaResponse(resp *http.Response, op string) (UserQuota, error) { if resp.StatusCode != http.StatusOK { return UserQuota{}, &HTTPStatusError{Operation: op, StatusCode: resp.StatusCode} } raw, err := io.ReadAll(resp.Body) if err != nil { return UserQuota{}, err } var payload struct { OCS struct { Meta struct { StatusCode int `json:"statuscode"` } `json:"meta"` Data struct { Quota struct { Free any `json:"free"` Used any `json:"used"` Total any `json:"total"` Relative any `json:"relative"` } `json:"quota"` } `json:"data"` } `json:"ocs"` } if err := json.Unmarshal(raw, &payload); err != nil { return UserQuota{}, err } if code := payload.OCS.Meta.StatusCode; code != 0 && code != 100 && code != 200 { return UserQuota{}, fmt.Errorf("%s: ocs status %d", op, code) } q := payload.OCS.Data.Quota used := parseAnyInt64(q.Used) free := parseAnyInt64(q.Free) total := parseAnyInt64(q.Total) if total <= 0 && used >= 0 && free >= 0 { total = used + free } return UserQuota{ Used: used, Free: free, Total: total, Relative: parseAnyInt64(q.Relative), }, nil } func parseQuotaPropfind(body io.Reader) (UserQuota, error) { var ms struct { XMLName xml.Name `xml:"multistatus"` Responses []struct { Propstat struct { Prop struct { Available int64 `xml:"quota-available-bytes"` Used int64 `xml:"quota-used-bytes"` } `xml:"prop"` } `xml:"propstat"` } `xml:"response"` } if err := xml.NewDecoder(body).Decode(&ms); err != nil { return UserQuota{}, err } if len(ms.Responses) == 0 { return UserQuota{}, fmt.Errorf("quota propfind: empty response") } used := ms.Responses[0].Propstat.Prop.Used free := ms.Responses[0].Propstat.Prop.Available total := used + free var relative int64 if total > 0 { relative = (used * 100) / total } return UserQuota{Used: used, Free: free, Total: total, Relative: relative}, nil } func ocsJSONHeaders() map[string]string { return map[string]string{ "Accept": "application/json", "OCS-APIRequest": "true", } } func fileModifiedTime(raw string) time.Time { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{} } if ts, err := time.Parse(time.RFC3339, raw); err == nil { return ts } if ts, err := time.Parse(time.RFC1123, raw); err == nil { return ts } if ts, err := http.ParseTime(raw); err == nil { return ts } return time.Time{} } // PROPFIND XML response parsing type multistatus struct { XMLName xml.Name `xml:"multistatus"` Responses []response `xml:"response"` } type response struct { Href string `xml:"href"` Propstat propstat `xml:"propstat"` } type propstat struct { Prop prop `xml:"prop"` Status string `xml:"status"` } type prop struct { LastModified string `xml:"getlastmodified"` ETag string `xml:"getetag"` ContentType string `xml:"getcontenttype"` ContentLength int64 `xml:"getcontentlength"` ResourceType resourceType `xml:"resourcetype"` Size int64 `xml:"size"` Favorite int `xml:"favorite"` ShareTypes shareTypes `xml:"http://owncloud.org/ns share-types"` FileID string `xml:"http://owncloud.org/ns fileid"` DisplayName string `xml:"displayname"` Permissions string `xml:"http://owncloud.org/ns permissions"` OwnerID string `xml:"http://owncloud.org/ns owner-id"` CalendarColor string `xml:"calendar-color"` } type resourceType struct { Collection *struct{} `xml:"collection"` } type shareTypes struct { ShareType []string `xml:"http://owncloud.org/ns share-type"` } func parsePropfindResponse(body io.Reader, listDir string) ([]FileInfo, error) { var ms multistatus if err := xml.NewDecoder(body).Decode(&ms); err != nil { return nil, err } files := make([]FileInfo, 0, len(ms.Responses)) for i, r := range ms.Responses { if i == 0 { continue // skip the folder itself } name := fileNameFromDAVProp(r.Propstat.Prop.DisplayName, r.Href) clientPath := ResolvePropfindClientPath(listDir, r.Href, name) name = SyncFileDisplayName(clientPath, name) fileType := "file" if r.Propstat.Prop.ResourceType.Collection != nil { fileType = "directory" } size := r.Propstat.Prop.ContentLength if r.Propstat.Prop.Size > 0 { size = r.Propstat.Prop.Size } files = append(files, FileInfo{ Path: clientPath, Name: name, Type: fileType, Size: size, MimeType: r.Propstat.Prop.ContentType, LastModified: r.Propstat.Prop.LastModified, ETag: strings.Trim(r.Propstat.Prop.ETag, "\""), FileID: parseFileID(r.Propstat.Prop.FileID), IsFavorite: r.Propstat.Prop.Favorite == 1, IsShared: len(r.Propstat.Prop.ShareTypes.ShareType) > 0, }) } return files, nil } func parseFileID(raw string) int64 { raw = strings.TrimSpace(raw) if raw == "" { return 0 } id, err := strconv.ParseInt(raw, 10, 64) if err != nil { return 0 } return id } func parseInt64(raw string) int64 { n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err != nil { return 0 } return n } func parseAnyInt64(raw any) int64 { switch v := raw.(type) { case nil: return 0 case float64: return int64(v) case int64: return v case int: return int64(v) case string: return parseInt64(v) default: return parseInt64(fmt.Sprintf("%v", v)) } }