package nextcloud import ( "context" "encoding/json" "encoding/xml" "fmt" "io" "net/http" "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"` IsFavorite bool `json:"is_favorite"` } type ShareInfo struct { ID string `json:"id"` Path string `json:"path"` ShareType int `json:"share_type"` Permissions int `json:"permissions"` URL string `json:"url"` ExpiresAt string `json:"expires_at,omitempty"` } 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, davPath) } 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) { 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 (c *Client) Move(ctx context.Context, userID, srcPath, destPath string) error { davSrc := c.WebDAVPath(userID, srcPath) destURL := c.baseURL + c.WebDAVPath(userID, destPath) resp, err := c.DoAsUser(ctx, "MOVE", davSrc, nil, userID, map[string]string{ "Destination": destURL, "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 { davSrc := c.WebDAVPath(userID, srcPath) destURL := c.baseURL + c.WebDAVPath(userID, destPath) resp, err := c.DoAsUser(ctx, "COPY", davSrc, nil, userID, map[string]string{ "Destination": destURL, "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.baseURL + 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", 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, basePath) } func (c *Client) ListRecent(ctx context.Context, userID string, limit int) ([]FileInfo, error) { path := "/ocs/v2.php/apps/files/api/v1/recent" if limit > 0 { path = fmt.Sprintf("%s?limit=%d", path, limit) } 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 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) } files = append(files, FileInfo{ Path: item.Path, Name: item.Name, Type: fileType, Size: item.Size, MimeType: item.MimeType, LastModified: lastModified, ETag: strings.Trim(item.ETag, "\""), }) } return files, nil } func (c *Client) CreateShare(ctx context.Context, userID, path string, shareType int, permissions int) (*ShareInfo, error) { formData := fmt.Sprintf("path=%s&shareType=%d&permissions=%d", path, shareType, permissions) resp, err := c.DoAsUser(ctx, "POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", strings.NewReader(formData), 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} } var ocsResp struct { OCS struct { Data struct { ID int `json:"id"` URL string `json:"url"` Permissions int `json:"permissions"` Expiration string `json:"expiration"` } `json:"data"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil { return nil, err } return &ShareInfo{ ID: fmt.Sprintf("%d", ocsResp.OCS.Data.ID), Path: path, ShareType: shareType, Permissions: ocsResp.OCS.Data.Permissions, URL: ocsResp.OCS.Data.URL, ExpiresAt: ocsResp.OCS.Data.Expiration, }, nil } func (c *Client) GetQuota(ctx context.Context, userID string) (UserQuota, error) { resp, err := c.DoAsUser(ctx, "GET", fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", userID), nil, userID, map[string]string{ "Accept": "application/json", }) if err != nil { return UserQuota{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return UserQuota{}, &HTTPStatusError{Operation: "get quota", StatusCode: resp.StatusCode} } var payload struct { OCS struct { Data struct { Quota struct { Free any `json:"free"` Used any `json:"used"` Total any `json:"total"` Relative int64 `json:"relative"` } `json:"quota"` } `json:"data"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return UserQuota{}, err } return UserQuota{ Used: parseAnyInt64(payload.OCS.Data.Quota.Used), Free: parseAnyInt64(payload.OCS.Data.Quota.Free), Total: parseAnyInt64(payload.OCS.Data.Quota.Total), Relative: payload.OCS.Data.Quota.Relative, }, nil } // 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"` DisplayName string `xml:"displayname"` CalendarColor string `xml:"calendar-color"` } type resourceType struct { Collection *struct{} `xml:"collection"` } func parsePropfindResponse(body io.Reader, basePath 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 := r.Href if idx := strings.LastIndex(strings.TrimSuffix(name, "/"), "/"); idx >= 0 { name = name[idx+1:] } name = strings.TrimSuffix(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: r.Href, Name: name, Type: fileType, Size: size, MimeType: r.Propstat.Prop.ContentType, LastModified: r.Propstat.Prop.LastModified, ETag: strings.Trim(r.Propstat.Prop.ETag, "\""), IsFavorite: r.Propstat.Prop.Favorite == 1, }) } return files, nil } 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)) } }