- 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.
364 lines
10 KiB
Go
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) + "/"
|
|
}
|