package nextcloud import ( "context" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" ) // PublicShareView describes a public link share (file or folder). type PublicShareView struct { Token string `json:"token"` Name string `json:"name"` ItemType string `json:"item_type"` Path string `json:"path"` Permissions int `json:"permissions"` OwnerID string `json:"owner_id,omitempty"` OwnerDisplayName string `json:"owner_displayname,omitempty"` Files []FileInfo `json:"files,omitempty"` File *FileInfo `json:"file,omitempty"` } func (c *Client) PublicShareURL(token string) string { token = strings.TrimSpace(token) if token == "" || c.drivePublicURL == "" { return "" } return strings.TrimRight(c.drivePublicURL, "/") + "/s/" + url.PathEscape(token) } func (c *Client) WithDrivePublicURL(publicURL string) *Client { if c == nil { return nil } c.drivePublicURL = strings.TrimRight(strings.TrimSpace(publicURL), "/") return c } func publicShareDAVPath(token, relPath string) string { token = strings.TrimSpace(token) relPath = NormalizeClientPath(relPath) base := "/public.php/dav/files/" + url.PathEscape(token) if relPath == "/" { return base + "/" } trimmed := strings.Trim(relPath, "/") parts := strings.Split(trimmed, "/") for i, p := range parts { parts[i] = url.PathEscape(p) } return base + "/" + strings.Join(parts, "/") } func clientPathFromPublicShareHref(href, token string) string { href = strings.TrimSpace(href) marker := "/public.php/dav/files/" + url.PathEscape(token) if idx := strings.Index(href, marker); idx >= 0 { rest := strings.TrimPrefix(href[idx+len(marker):], "/") if rest == "" { return "/" } return decodeDAVPath("/" + rest) } // Fallback: strip through token segment in path. parts := strings.Split(strings.Trim(href, "/"), "/") for i, p := range parts { if decodeDAVSegment(p) == token && i+1 < len(parts) { return decodeDAVPath("/" + strings.Join(parts[i+1:], "/")) } } return "/" } func (c *Client) GetPublicShare(ctx context.Context, token, relPath, password string) (*PublicShareView, error) { token = strings.TrimSpace(token) if token == "" { return nil, ErrInvalidPublicShare } relPath = NormalizeClientPath(relPath) resp, err := c.publicShareRequest(ctx, "PROPFIND", token, relPath, strings.NewReader(propfindListBody), password, map[string]string{ "Depth": "1", "Content-Type": "application/xml", }) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return nil, ErrPublicSharePasswordRequired } if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { return nil, &HTTPStatusError{Operation: "public share propfind", StatusCode: resp.StatusCode} } root, children, permissions, err := parsePublicSharePropfind(resp.Body, token, relPath) if err != nil { return nil, err } view := &PublicShareView{ Token: token, Path: relPath, Name: root.Name, Permissions: permissions, } if sharePerms, permErr := c.GetPublicSharePermissions(ctx, token, password); permErr == nil { view.Permissions = sharePerms } if root.Type == "directory" { view.ItemType = "folder" view.Files = children } else { view.ItemType = "file" view.File = &root } c.enrichPublicShareOwner(ctx, view, token, password) return view, nil } // PreviewPublicShare returns a thumbnail/preview image for a file in a public link share. func (c *Client) PreviewPublicShare(ctx context.Context, token, filePath, password string, width, height int) (io.ReadCloser, string, error) { token = strings.TrimSpace(token) if token == "" { return nil, "", ErrInvalidPublicShare } filePath = NormalizeClientPath(filePath) if width <= 0 { width = 400 } if height <= 0 { height = 300 } if width > 2048 { width = 2048 } if height > 2048 { height = 2048 } q := url.Values{} q.Set("file", filePath) q.Set("x", strconv.Itoa(width)) q.Set("y", strconv.Itoa(height)) q.Set("a", "1") previewPath := fmt.Sprintf( "/index.php/apps/files_sharing/publicpreview/%s?%s", url.PathEscape(token), q.Encode(), ) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+previewPath, nil) if err != nil { return nil, "", err } req.Header.Set("X-Requested-With", "XMLHttpRequest") if password != "" { req.SetBasicAuth("", password) } resp, err := c.httpClient.Do(req) if err != nil { return nil, "", err } if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { resp.Body.Close() return nil, "", ErrPublicSharePasswordRequired } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, "", &HTTPStatusError{Operation: "public share preview", StatusCode: resp.StatusCode} } contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) if contentType == "" { contentType = "image/jpeg" } return resp.Body, contentType, nil } func (c *Client) DownloadPublicShare(ctx context.Context, token, filePath, password string) (io.ReadCloser, string, error) { token = strings.TrimSpace(token) if token == "" { return nil, "", ErrInvalidPublicShare } filePath = NormalizeClientPath(filePath) body, contentType, err := c.downloadPublicShareAt(ctx, token, filePath, password) if err == nil { return body, contentType, nil } // Single-file shares expose content at the WebDAV root, not /filename. var statusErr *HTTPStatusError if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound && filePath != "/" { return c.downloadPublicShareAt(ctx, token, "/", password) } return nil, "", err } func (c *Client) downloadPublicShareAt(ctx context.Context, token, filePath, password string) (io.ReadCloser, string, error) { davPath := publicShareDAVPath(token, filePath) resp, err := c.publicShareRequestRaw(ctx, http.MethodGet, davPath, nil, password, nil) if err != nil { return nil, "", err } if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { resp.Body.Close() return nil, "", ErrPublicSharePasswordRequired } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, "", &HTTPStatusError{Operation: "public share download", StatusCode: resp.StatusCode} } contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) if contentType == "" { contentType = "application/octet-stream" } return resp.Body, contentType, nil } func (c *Client) publicShareRequest(ctx context.Context, method, token, relPath string, body io.Reader, password string, headers map[string]string) (*http.Response, error) { return c.publicShareRequestRaw(ctx, method, publicShareDAVPath(token, relPath), body, password, headers) } func (c *Client) publicShareRequestRaw(ctx context.Context, method, davPath string, body io.Reader, password string, headers map[string]string) (*http.Response, error) { if !strings.HasPrefix(davPath, "/") { davPath = "/" + davPath } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+davPath, body) if err != nil { return nil, err } req.Header.Set("X-Requested-With", "XMLHttpRequest") if password != "" { req.SetBasicAuth("", password) } for k, v := range headers { req.Header.Set(k, v) } return c.httpClient.Do(req) } const propfindListBody = ` ` const propfindOwnerBody = ` ` func (c *Client) enrichPublicShareOwner(ctx context.Context, view *PublicShareView, token, password string) { if c == nil || view == nil { return } ownerID, err := c.getPublicShareOwnerID(ctx, token, password) if err != nil || strings.TrimSpace(ownerID) == "" { return } view.OwnerID = ownerID if name, err := c.UserDisplayName(ctx, ownerID); err == nil && strings.TrimSpace(name) != "" { view.OwnerDisplayName = strings.TrimSpace(name) } } func (c *Client) getPublicShareOwnerID(ctx context.Context, token, password string) (string, error) { resp, err := c.publicShareRequest(ctx, "PROPFIND", token, "/", strings.NewReader(propfindOwnerBody), password, map[string]string{ "Depth": "0", "Content-Type": "application/xml", }) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return "", ErrPublicSharePasswordRequired } if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { return "", &HTTPStatusError{Operation: "public share owner", StatusCode: resp.StatusCode} } var ms multistatus if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil { return "", err } if len(ms.Responses) == 0 { return "", nil } return strings.TrimSpace(ms.Responses[0].Propstat.Prop.OwnerID), nil } const propfindPermBody = ` ` func (c *Client) GetPublicSharePermissions(ctx context.Context, token, password string) (int, error) { return c.GetPublicSharePathPermissions(ctx, token, "/", password) } // EffectivePublicSharePermissions returns share permissions for a path. // Nextcloud often omits oc:permissions on nested WebDAV nodes; fall back to root share bits. func (c *Client) EffectivePublicSharePermissions(ctx context.Context, token, relPath, password string) (int, error) { root, err := c.GetPublicSharePermissions(ctx, token, password) if err != nil { return 0, err } relPath = NormalizeClientPath(relPath) if relPath == "/" { return root, nil } pathPerms, err := c.GetPublicSharePathPermissions(ctx, token, relPath, password) if err != nil || pathPerms == 0 { return root, nil } return root | pathPerms, nil } const propfindPublicRevisionBody = ` ` func (c *Client) PublicShareFileRevision(ctx context.Context, token, filePath, password string) (FileRevision, error) { token = strings.TrimSpace(token) if token == "" { return FileRevision{}, ErrInvalidPublicShare } filePath = NormalizeClientPath(filePath) rev, err := c.publicShareFileRevisionAt(ctx, token, filePath, password) if err == nil { return rev, nil } var statusErr *HTTPStatusError if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound && filePath != "/" { return c.publicShareFileRevisionAt(ctx, token, "/", password) } return FileRevision{}, err } func (c *Client) publicShareFileRevisionAt(ctx context.Context, token, filePath, password string) (FileRevision, error) { resp, err := c.publicShareRequest(ctx, "PROPFIND", token, filePath, strings.NewReader(propfindPublicRevisionBody), password, map[string]string{ "Depth": "0", "Content-Type": "application/xml", }) if err != nil { return FileRevision{}, err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return FileRevision{}, ErrPublicSharePasswordRequired } if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { return FileRevision{}, &HTTPStatusError{Operation: "public share file revision", StatusCode: resp.StatusCode} } var ms multistatus if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil { return FileRevision{}, err } if len(ms.Responses) == 0 { return FileRevision{}, fmt.Errorf("public share file revision: empty response") } raw := strings.TrimSpace(ms.Responses[0].Propstat.Prop.FileID) if raw == "" { return FileRevision{}, fmt.Errorf("public share file revision: missing fileid") } id, err := strconv.ParseInt(raw, 10, 64) if err != nil { return FileRevision{}, fmt.Errorf("public share file revision: invalid fileid %q", raw) } etag := strings.Trim(strings.TrimSpace(ms.Responses[0].Propstat.Prop.ETag), "\"") return FileRevision{FileID: id, ETag: etag}, nil } func (c *Client) GetPublicSharePathPermissions(ctx context.Context, token, relPath, password string) (int, error) { token = strings.TrimSpace(token) if token == "" { return 0, ErrInvalidPublicShare } relPath = NormalizeClientPath(relPath) resp, err := c.publicShareRequest(ctx, "PROPFIND", token, relPath, strings.NewReader(propfindPermBody), password, map[string]string{ "Depth": "0", "Content-Type": "application/xml", }) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return 0, ErrPublicSharePasswordRequired } if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { return 0, &HTTPStatusError{Operation: "public share permissions", StatusCode: resp.StatusCode} } var ms multistatus if err := xml.NewDecoder(resp.Body).Decode(&ms); err != nil { return 0, err } if len(ms.Responses) == 0 { return 0, fmt.Errorf("public share: empty permissions response") } return ParseOCPermissionLetters(ms.Responses[0].Propstat.Prop.Permissions), nil } func parsePublicSharePropfind(body io.Reader, token, listDir string) (FileInfo, []FileInfo, int, error) { var ms multistatus if err := xml.NewDecoder(body).Decode(&ms); err != nil { return FileInfo{}, nil, 0, err } if len(ms.Responses) == 0 { return FileInfo{}, nil, 0, fmt.Errorf("public share: empty response") } root := fileInfoFromPublicDAV(ms.Responses[0], token, listDir) permissions := ParseOCPermissionLetters(ms.Responses[0].Propstat.Prop.Permissions) children := make([]FileInfo, 0, len(ms.Responses)-1) for i := 1; i < len(ms.Responses); i++ { child := fileInfoFromPublicDAV(ms.Responses[i], token, listDir) if child.Name == "" { continue } children = append(children, child) } return root, children, permissions, nil } func fileInfoFromPublicDAV(r response, token, listDir string) FileInfo { name := fileNameFromDAVProp(r.Propstat.Prop.DisplayName, r.Href) clientPath := clientPathFromPublicShareHref(r.Href, token) if name == "" { name = pathBaseName(strings.Trim(clientPath, "/")) } if clientPath == "/" && name == "" { name = "Partage" } 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 } displayPath := clientPath if displayPath == "/" && fileType == "file" { // Single-file shares: WebDAV root is the file itself. displayPath = "/" } else if displayPath == "/" { displayPath = "/" + name } return FileInfo{ Path: displayPath, Name: name, Type: fileType, Size: size, MimeType: r.Propstat.Prop.ContentType, LastModified: r.Propstat.Prop.LastModified, ETag: strings.Trim(r.Propstat.Prop.ETag, "\""), } } var ( ErrInvalidPublicShare = errors.New("invalid public share token") ErrPublicSharePasswordRequired = errors.New("public share password required") ErrPublicShareReadOnly = errors.New("public share read only") ) func (c *Client) UploadPublicShare(ctx context.Context, token, filePath, password string, content io.Reader, contentType string) error { token = strings.TrimSpace(token) if token == "" { return ErrInvalidPublicShare } filePath = NormalizeClientPath(filePath) headers := map[string]string{} if contentType != "" { headers["Content-Type"] = contentType } resp, err := c.publicShareRequest(ctx, http.MethodPut, token, filePath, content, password, headers) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return ErrPublicSharePasswordRequired } if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return &HTTPStatusError{Operation: "public share upload", StatusCode: resp.StatusCode} } return nil } func (c *Client) CreatePublicShareFolder(ctx context.Context, token, folderPath, password string) error { token = strings.TrimSpace(token) if token == "" { return ErrInvalidPublicShare } folderPath = NormalizeClientPath(folderPath) resp, err := c.publicShareRequest(ctx, "MKCOL", token, folderPath, nil, password, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return ErrPublicSharePasswordRequired } if resp.StatusCode != http.StatusCreated { return &HTTPStatusError{Operation: "public share mkcol", StatusCode: resp.StatusCode} } return nil } func (c *Client) DeletePublicShare(ctx context.Context, token, filePath, password string) error { token = strings.TrimSpace(token) if token == "" { return ErrInvalidPublicShare } filePath = NormalizeClientPath(filePath) resp, err := c.publicShareRequest(ctx, http.MethodDelete, token, filePath, nil, password, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return ErrPublicSharePasswordRequired } if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return &HTTPStatusError{Operation: "public share delete", StatusCode: resp.StatusCode} } return nil } func (c *Client) MovePublicShare(ctx context.Context, token, srcPath, destPath, password string) error { token = strings.TrimSpace(token) if token == "" { return ErrInvalidPublicShare } srcPath = NormalizeClientPath(srcPath) destPath = NormalizeClientPath(destPath) srcDAV := publicShareDAVPath(token, srcPath) destDAV := publicShareDAVPath(token, destPath) resp, err := c.publicShareRequestRaw(ctx, "MOVE", srcDAV, nil, password, map[string]string{ "Destination": c.webDAVDestination(destDAV), "Overwrite": "F", }) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return ErrPublicSharePasswordRequired } if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { return &HTTPStatusError{Operation: "public share move", StatusCode: resp.StatusCode} } return nil }