package nextcloud import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" ) type ExternalMount struct { ID int `json:"id"` MountPoint string `json:"mount_point"` Backend string `json:"backend"` Status int `json:"status"` } type WebDAVMountConfig struct { Host string `json:"host"` Root string `json:"root"` User string `json:"user"` Password string `json:"password"` Secure bool `json:"secure"` } // CreateUserWebDAVMount registers a WebDAV external storage mount for a user. func (c *Client) CreateUserWebDAVMount(ctx context.Context, userID, mountPoint string, cfg WebDAVMountConfig) (int, error) { return c.createExternalMount(ctx, mountPoint, "dav", "password::password", userID, map[string]string{ "host": cfg.Host, "root": cfg.Root, "user": cfg.User, "password": cfg.Password, "secure": boolString(cfg.Secure), }) } // CreateGlobalWebDAVMount registers an org-wide WebDAV mount (all users). func (c *Client) CreateGlobalWebDAVMount(ctx context.Context, mountPoint string, cfg WebDAVMountConfig) (int, error) { return c.createExternalMount(ctx, mountPoint, "dav", "password::password", "", map[string]string{ "host": cfg.Host, "root": cfg.Root, "user": cfg.User, "password": cfg.Password, "secure": boolString(cfg.Secure), }) } func boolString(v bool) string { if v { return "true" } return "false" } func (c *Client) createExternalMount(ctx context.Context, mountPoint, backend, authBackend, userID string, config map[string]string) (int, error) { form := url.Values{} form.Set("mountPoint", mountPoint) form.Set("backend", backend) form.Set("authBackend", authBackend) if userID != "" { form.Set("user", userID) } for k, v := range config { form.Set("config["+k+"]", v) } apiPath := "/index.php/apps/files_external/api/v1/mounts?format=json" resp, err := c.doRequest(ctx, "POST", apiPath, strings.NewReader(form.Encode()), map[string]string{ "Content-Type": "application/x-www-form-urlencoded", }) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return 0, fmt.Errorf("create external mount: %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var payload struct { OCS struct { Data struct { ID int `json:"id"` } `json:"data"` } `json:"ocs"` } if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { return 0, err } if payload.OCS.Data.ID <= 0 { return 0, fmt.Errorf("external mount create returned empty id") } return payload.OCS.Data.ID, nil } func (c *Client) DeleteExternalMount(ctx context.Context, mountID int) error { apiPath := fmt.Sprintf("/index.php/apps/files_external/api/v1/mounts/%d?format=json", mountID) resp, err := c.doRequest(ctx, "DELETE", apiPath, nil, ocsJSONHeaders()) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return &HTTPStatusError{Operation: "delete external mount", StatusCode: resp.StatusCode} } return nil } func (c *Client) ListUserExternalMounts(ctx context.Context, userID string) ([]ExternalMount, error) { apiPath := "/index.php/apps/files_external/api/v1/mounts?format=json" resp, err := c.DoAsUser(ctx, "GET", apiPath, nil, userID, ocsJSONHeaders()) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, &HTTPStatusError{Operation: "list external mounts", StatusCode: resp.StatusCode} } return decodeExternalMounts(resp.Body) } func (c *Client) ListGlobalExternalMounts(ctx context.Context) ([]ExternalMount, error) { apiPath := "/index.php/apps/files_external/globalstorages?format=json" resp, err := c.doRequest(ctx, "GET", apiPath, nil, ocsJSONHeaders()) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, &HTTPStatusError{Operation: "list global external mounts", StatusCode: resp.StatusCode} } return decodeExternalMounts(resp.Body) } func decodeExternalMounts(body io.Reader) ([]ExternalMount, error) { var payload struct { OCS struct { Data json.RawMessage `json:"data"` } `json:"ocs"` } if err := json.NewDecoder(body).Decode(&payload); err != nil { return nil, err } raw := payload.OCS.Data if len(raw) == 0 || string(raw) == "null" { return nil, nil } var mounts []ExternalMount if err := json.Unmarshal(raw, &mounts); err == nil { return mounts, nil } var asMap map[string]ExternalMount if err := json.Unmarshal(raw, &asMap); err != nil { return nil, err } out := make([]ExternalMount, 0, len(asMap)) for _, m := range asMap { out = append(out, m) } return out, nil } // CreateOAuthExternalMount creates a mount using OAuth2 backend (Google, Dropbox, etc.). func (c *Client) CreateOAuthExternalMount(ctx context.Context, userID, mountPoint, backend, authBackend string, oauthConfig map[string]string) (int, error) { config := map[string]string{ "configured": "false", "token": "", } for k, v := range oauthConfig { config[k] = v } return c.createExternalMount(ctx, mountPoint, backend, authBackend, userID, config) } type OAuth2StepResult struct { URL string Token string } func (c *Client) StartExternalStorageOAuth2(ctx context.Context, userID, clientID, clientSecret, redirectURI string) (string, error) { result, err := c.postExternalStorageOAuth2(ctx, userID, map[string]string{ "step": "1", "client_id": clientID, "client_secret": clientSecret, "redirect": redirectURI, }) if err != nil { return "", err } if result.URL == "" { return "", fmt.Errorf("oauth2 step 1: empty authorization url") } return result.URL, nil } func (c *Client) CompleteExternalStorageOAuth2(ctx context.Context, userID, clientID, clientSecret, redirectURI, code string) (string, error) { result, err := c.postExternalStorageOAuth2(ctx, userID, map[string]string{ "step": "2", "client_id": clientID, "client_secret": clientSecret, "redirect": redirectURI, "code": code, }) if err != nil { return "", err } if result.Token == "" { return "", fmt.Errorf("oauth2 step 2: empty token") } return result.Token, nil } func (c *Client) UpdateUserExternalMountOAuth(ctx context.Context, userID string, mountID int, clientID, clientSecret, token string) error { form := url.Values{} form.Set("client_id", clientID) form.Set("client_secret", clientSecret) form.Set("token", token) form.Set("configured", "true") apiPath := fmt.Sprintf("/index.php/apps/files_external/userstorages/%d?format=json", mountID) resp, err := c.DoAsUser(ctx, "PUT", apiPath, strings.NewReader(form.Encode()), userID, map[string]string{ "Content-Type": "application/x-www-form-urlencoded", }) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("update external mount oauth: %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } return nil } func (c *Client) postExternalStorageOAuth2(ctx context.Context, userID string, fields map[string]string) (OAuth2StepResult, error) { form := url.Values{} for k, v := range fields { form.Set(k, v) } apiPath := "/index.php/apps/files_external/ajax/oauth2.php" resp, err := c.DoAsUser(ctx, "POST", apiPath, strings.NewReader(form.Encode()), userID, map[string]string{ "Content-Type": "application/x-www-form-urlencoded", }) if err != nil { return OAuth2StepResult{}, err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 8192)) if err != nil { return OAuth2StepResult{}, err } if resp.StatusCode != http.StatusOK { return OAuth2StepResult{}, fmt.Errorf("oauth2 request: %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var payload struct { Status string `json:"status"` Data struct { URL string `json:"url"` Token string `json:"token"` Message string `json:"message"` } `json:"data"` } if err := json.Unmarshal(body, &payload); err != nil { return OAuth2StepResult{}, fmt.Errorf("oauth2 response decode: %w", err) } if payload.Status != "success" { msg := strings.TrimSpace(payload.Data.Message) if msg == "" { msg = strings.TrimSpace(string(body)) } return OAuth2StepResult{}, fmt.Errorf("oauth2 failed: %s", msg) } return OAuth2StepResult{URL: payload.Data.URL, Token: payload.Data.Token}, nil } func ParseMountID(raw string) (int, error) { return strconv.Atoi(strings.TrimSpace(raw)) }