ultisuite-backend/internal/nextcloud/users.go
R3D347HR4Y 556d5f416d Enhance API and configuration for contact discovery and public sharing
- Introduced new endpoints for contact discovery, including scanning, listing, and managing discovered contacts.
- Implemented retry logic for handling missing DAV credentials during contact operations.
- Added public share functionality for drive API, allowing users to manage public shares, including upload, delete, and rename operations.
- Updated Nextcloud configuration to support public share links and improved error handling for public share permissions.
- Enhanced logging and validation across contact and drive APIs for better error tracking and user feedback.
- Added tests for new contact matching and ranking functionalities to ensure accuracy and reliability.
2026-06-06 20:27:02 +02:00

364 lines
10 KiB
Go

package nextcloud
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"strings"
)
var ErrPrincipalNotFound = errors.New("nextcloud principal not found")
// UserIDFromClaims returns the Nextcloud account id aligned with user_oidc mapping-uid
// (preferred_username / enrollment email), not the opaque OIDC sub.
func UserIDFromClaims(email, sub string) string {
email = strings.TrimSpace(strings.ToLower(email))
if email != "" {
return email
}
return strings.TrimSpace(sub)
}
// EnsurePrincipal provisions a Nextcloud user and CardDAV app credentials.
func (c *Client) EnsurePrincipal(ctx context.Context, email, sub, displayName string) (string, error) {
if c.credStore == nil {
return "", fmt.Errorf("nextcloud dav credentials store not configured")
}
userID := UserIDFromClaims(email, sub)
if userID == "" {
return "", fmt.Errorf("nextcloud user id is empty")
}
token, err := c.credStore.GetToken(ctx, userID)
if err == nil && token != "" {
return userID, nil
}
exists, err := c.userExists(ctx, userID)
if err != nil {
return "", err
}
provisionEmail := strings.TrimSpace(email)
if provisionEmail == "" {
provisionEmail = userID
}
name := strings.TrimSpace(displayName)
if name == "" {
name = provisionEmail
}
loginPassword, err := generateNextcloudPassword()
if err != nil {
return "", err
}
if !exists {
if err := c.createUser(ctx, userID, provisionEmail, name, loginPassword); err != nil {
return "", err
}
} else if err := c.setUserPassword(ctx, userID, loginPassword); err != nil {
return "", err
}
appPassword, err := c.createAppPassword(ctx, userID, loginPassword)
if err != nil {
return "", err
}
if err := c.credStore.SaveToken(ctx, userID, appPassword); err != nil {
return "", err
}
return userID, nil
}
// InvalidatePrincipalCredentials removes stored CardDAV app credentials for a user.
func (c *Client) InvalidatePrincipalCredentials(ctx context.Context, userID string) error {
if c.credStore == nil {
return nil
}
return c.credStore.DeleteToken(ctx, userID)
}
// RefreshPrincipalCredentials rotates the Nextcloud login password and app password for an existing user.
func (c *Client) RefreshPrincipalCredentials(ctx context.Context, userID string) error {
if c.credStore == nil {
return fmt.Errorf("nextcloud dav credentials store not configured")
}
userID = strings.TrimSpace(userID)
if userID == "" {
return fmt.Errorf("nextcloud user id is empty")
}
loginPassword, err := generateNextcloudPassword()
if err != nil {
return err
}
if err := c.setUserPassword(ctx, userID, loginPassword); err != nil {
return err
}
appPassword, err := c.createAppPassword(ctx, userID, loginPassword)
if err != nil {
return err
}
return c.credStore.SaveToken(ctx, userID, appPassword)
}
// UserExists reports whether a Nextcloud account exists for the given user id (typically email).
func (c *Client) UserExists(ctx context.Context, userID string) (bool, error) {
return c.userExists(ctx, userID)
}
// UserDisplayName returns the Nextcloud account display name for a user id.
func (c *Client) UserDisplayName(ctx context.Context, userID string) (string, error) {
userID = strings.TrimSpace(userID)
if userID == "" {
return "", fmt.Errorf("empty user id")
}
path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", url.PathEscape(userID))
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, map[string]string{
"Accept": "application/json",
})
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", &HTTPStatusError{Operation: "get user display name", StatusCode: resp.StatusCode}
}
var payload struct {
OCS struct {
Data struct {
DisplayName string `json:"displayname"`
ID string `json:"id"`
} `json:"data"`
} `json:"ocs"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", err
}
name := strings.TrimSpace(payload.OCS.Data.DisplayName)
if name != "" {
return name, nil
}
return strings.TrimSpace(payload.OCS.Data.ID), nil
}
func (c *Client) userExists(ctx context.Context, userID string) (bool, error) {
path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s?format=json", url.PathEscape(userID))
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, map[string]string{
"OCS-APIRequest": "true",
"Accept": "application/json",
})
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return false, nil
}
if resp.StatusCode != http.StatusOK {
return false, &HTTPStatusError{Operation: "get user", StatusCode: resp.StatusCode}
}
var payload struct {
OCS struct {
Meta struct {
StatusCode int `json:"statuscode"`
} `json:"meta"`
} `json:"ocs"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return false, err
}
return payload.OCS.Meta.StatusCode == 100, nil
}
func (c *Client) setUserPassword(ctx context.Context, userID, password string) error {
form := url.Values{}
form.Set("key", "password")
form.Set("value", password)
path := fmt.Sprintf("/ocs/v1.php/cloud/users/%s", url.PathEscape(userID))
resp, err := c.doRequest(ctx, http.MethodPut, path, strings.NewReader(form.Encode()), map[string]string{
"OCS-APIRequest": "true",
"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: "set user password", StatusCode: resp.StatusCode}
}
var payload struct {
OCS struct {
Meta struct {
Status string `json:"status"`
StatusCode int `json:"statuscode"`
} `json:"meta"`
} `json:"ocs"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return err
}
if strings.EqualFold(payload.OCS.Meta.Status, "ok") || payload.OCS.Meta.StatusCode == 100 {
return nil
}
return fmt.Errorf("set nextcloud user password failed with status %d", payload.OCS.Meta.StatusCode)
}
func (c *Client) createAppPassword(ctx context.Context, userID, loginPassword string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/ocs/v2.php/core/getapppassword?format=json", nil)
if err != nil {
return "", err
}
req.SetBasicAuth(userID, loginPassword)
req.Header.Set("OCS-APIRequest", "true")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", &HTTPStatusError{Operation: "create app password", StatusCode: resp.StatusCode}
}
var payload struct {
OCS struct {
Data struct {
AppPassword string `json:"apppassword"`
} `json:"data"`
} `json:"ocs"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", err
}
token := strings.TrimSpace(payload.OCS.Data.AppPassword)
if token == "" {
return "", fmt.Errorf("nextcloud app password response empty")
}
return token, nil
}
func (c *Client) createUser(ctx context.Context, userID, email, displayName, password string) error {
form := url.Values{}
form.Set("userid", userID)
form.Set("password", password)
form.Set("email", email)
form.Set("displayName", displayName)
resp, err := c.doRequest(ctx, http.MethodPost, "/ocs/v1.php/cloud/users?format=json", strings.NewReader(form.Encode()), map[string]string{
"OCS-APIRequest": "true",
"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: "create user", StatusCode: resp.StatusCode}
}
var payload struct {
OCS struct {
Meta struct {
Status string `json:"status"`
StatusCode int `json:"statuscode"`
Message string `json:"message"`
} `json:"meta"`
} `json:"ocs"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return err
}
if strings.EqualFold(payload.OCS.Meta.Status, "ok") || payload.OCS.Meta.StatusCode == 100 {
return nil
}
if payload.OCS.Meta.Message != "" {
return fmt.Errorf("create nextcloud user: %s", payload.OCS.Meta.Message)
}
return fmt.Errorf("create nextcloud user failed with status %d", payload.OCS.Meta.StatusCode)
}
func generateNextcloudPassword() (string, error) {
const (
lower = "abcdefghijklmnopqrstuvwxyz"
upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
digits = "0123456789"
symbols = "!@#$%^&*()-_=+"
all = lower + upper + digits + symbols
)
pick := func(chars string) (byte, error) {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return 0, err
}
return chars[n.Int64()], nil
}
out := make([]byte, 32)
required := []string{lower, upper, digits, symbols}
for i, chars := range required {
b, err := pick(chars)
if err != nil {
return "", err
}
out[i] = b
}
for i := len(required); i < len(out); i++ {
b, err := pick(all)
if err != nil {
return "", err
}
out[i] = b
}
for i := len(out) - 1; i > 0; i-- {
j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
return "", err
}
out[i], out[j.Int64()] = out[j.Int64()], out[i]
}
return string(out), nil
}
func readResponseBody(resp *http.Response) ([]byte, error) {
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return raw, nil
}
func davResponseError(raw []byte, statusCode int) error {
if statusCode == http.StatusNotFound {
return ErrPrincipalNotFound
}
if statusCode != http.StatusMultiStatus && statusCode != http.StatusOK {
if strings.Contains(string(raw), "<d:error") {
return &HTTPStatusError{Operation: "carddav", StatusCode: statusCode}
}
return &HTTPStatusError{Operation: "carddav", StatusCode: statusCode}
}
return nil
}
func addressBookHomePath(userID string) string {
return fmt.Sprintf("/remote.php/dav/addressbooks/users/%s/", url.PathEscape(userID))
}
// AddressBookPath returns the CardDAV collection path for a user's address book.
func AddressBookPath(userID, bookID string) string {
return addressBookHomePath(userID) + url.PathEscape(bookID) + "/"
}