- Updated environment configuration to unify frontend for mail and drive under a single service. - Revised README to reflect changes in frontend setup and routing for the unified application. - Introduced new API documentation endpoints for better accessibility of API specifications. - Enhanced drive and mail services with improved handling of file uploads and metadata enrichment. - Implemented new API token management features, including creation, listing, and revocation of tokens. - Added tests for new functionalities in drive and mail services to ensure reliability and correctness.
1090 lines
31 KiB
Go
1090 lines
31 KiB
Go
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
|
<d:prop>
|
|
<d:getlastmodified/>
|
|
<d:getetag/>
|
|
<d:getcontenttype/>
|
|
<d:getcontentlength/>
|
|
<d:resourcetype/>
|
|
<oc:fileid/>
|
|
<oc:size/>
|
|
<oc:favorite/>
|
|
<oc:share-types/>
|
|
<d:displayname/>
|
|
</d:prop>
|
|
</d:propfind>`
|
|
|
|
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
|
<d:prop>
|
|
<d:getlastmodified/>
|
|
<d:getetag/>
|
|
<d:getcontenttype/>
|
|
<d:getcontentlength/>
|
|
<d:resourcetype/>
|
|
<oc:size/>
|
|
<oc:favorite/>
|
|
<d:displayname/>
|
|
</d:prop>
|
|
</d:propfind>`
|
|
|
|
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(`<?xml version="1.0"?>
|
|
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
|
<d:set>
|
|
<d:prop><oc:favorite>%s</oc:favorite></d:prop>
|
|
</d:set>
|
|
</d:propertyupdate>`, 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 := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<d:propfind xmlns:d="DAV:">
|
|
<d:prop>
|
|
<d:quota-available-bytes/>
|
|
<d:quota-used-bytes/>
|
|
</d:prop>
|
|
</d:propfind>`
|
|
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))
|
|
}
|
|
}
|