- Added new endpoints for listing trash, recent, and starred files. - Implemented chunked file uploads to support large file handling. - Introduced copy and rename functionalities for file management. - Enhanced error handling with specific drive-related error responses. - Updated validation for copy and rename requests. - Improved service methods to handle new functionalities and ensure quota checks. - Updated project checklist to reflect completion of file management features.
487 lines
13 KiB
Go
487 lines
13 KiB
Go
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 := `<?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/>
|
|
</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, 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 := `<?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: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, 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))
|
|
}
|
|
}
|