ultisuite-backend/internal/nextcloud/public_share.go
R3D347HR4Y d3c930cac6
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(identity-providers): add management for identity providers in admin API
- Introduced new endpoints for managing identity providers, including retrieval of redirect URIs and testing/syncing providers.
- Enhanced organization settings to include identity provider configurations, allowing for self-enrollment and domain restrictions.
- Implemented caching for access policies and added validation for identity provider secrets.
- Added integration tests to ensure proper functionality of identity provider management and policy enforcement.
2026-06-09 09:36:38 +02:00

571 lines
18 KiB
Go

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 = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<d:getlastmodified/>
<d:getcontenttype/>
<d:getcontentlength/>
<d:resourcetype/>
<d:getetag/>
<d:displayname/>
<oc:size/>
<oc:permissions/>
</d:prop>
</d:propfind>`
const propfindOwnerBody = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<oc:owner-id/>
</d:prop>
</d:propfind>`
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 = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<oc:permissions/>
</d:prop>
</d:propfind>`
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 = `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<oc:fileid/>
<d:getetag/>
</d:prop>
</d:propfind>`
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)
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
}