Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions docs/nextcloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,3 +579,91 @@ Authorization: Bearer eyJhbG...
```http
HTTP/1.1 204 No Content
```

## POST /remote/nextcloud/migration

This route triggers a one-shot bulk migration of a user's Nextcloud files into
their Cozy. The Stack validates the credentials, persists an
`io.cozy.accounts` document, creates an `io.cozy.nextcloud.migrations`
tracking document in `pending` state, and publishes a
`nextcloud.migration.requested` command to the `migration` RabbitMQ exchange.
The actual transfer is performed by an external migration service that
consumes the command and drives the existing `/remote/nextcloud/:account/*`
routes, updating the tracking document as it progresses.

Before persisting anything, the Stack probes the supplied credentials against
the Nextcloud instance via the OCS `user_status` endpoint, so wrong passwords
and unreachable hosts surface synchronously instead of being deferred to the
migration service. The probe also resolves the WebDAV user ID, which is
cached on the account document so the migration service does not need to
re-fetch it.

When an existing `io.cozy.accounts` document for the same `account_type:
"nextcloud"` + `auth.url` + `auth.login` triplet is found, it is reused with
its stored password and `webdav_user_id` refreshed from the request. Only one
migration can be in flight per instance at a time: if a `pending` or `running`
tracking document already exists, the Stack returns `409 Conflict`. Failed
migrations do not block new attempts.

**Note:** a permission on `POST io.cozy.nextcloud.migrations` is required to
use this route.

### Request

```http
POST /remote/nextcloud/migration HTTP/1.1
Host: cozy.example.net
Authorization: Bearer eyJhbG...
Content-Type: application/json
```

```json
{
"nextcloud_url": "https://nextcloud.example.com",
"nextcloud_login": "alice",
"nextcloud_app_password": "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx",
"source_path": "/"
}
```

`source_path` is optional and defaults to `/`. The `nextcloud_app_password`
should be a Nextcloud app password, not the user's main account password.

### Response

```http
HTTP/1.1 201 Created
Content-Type: application/vnd.api+json
```

```json
{
"data": {
"id": "d4e5f6a7b8c94d0ea1b2c3d4e5f6a7b8",
"type": "io.cozy.nextcloud.migrations",
"attributes": {
"status": "pending",
"target_dir": "/Nextcloud",
"progress": {
"files_imported": 0,
"files_total": 0,
"bytes_imported": 0,
"bytes_total": 0
},
"errors": [],
"skipped": [],
"started_at": null,
"finished_at": null
}
}
}
```

#### Status codes

- 201 Created, when the migration has been queued and the tracking document is returned
- 401 Unauthorized, when the Nextcloud credentials are rejected by the remote host
- 409 Conflict, when a `pending` or `running` migration already exists
- 500 Internal Server Error, when the conflict check, account upsert, or tracking document creation fails
- 502 Bad Gateway, when the Nextcloud instance is unreachable
- 503 Service Unavailable, when the migration command cannot be published to RabbitMQ. The tracking document is marked `failed` before returning
40 changes: 40 additions & 0 deletions docs/rabbitmq.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,46 @@ rabbitmq:
- If queue-level `dlx_name`/`dlq_name` are not specified, exchange-level defaults are used.
- Messages that exceed the `delivery_limit` or are rejected will be sent to the DLX and routed to the DLQ.

### Publishers

The Stack also publishes messages to RabbitMQ. Publishers do not declare any
queue or exchange on the Stack side: the exchange must already exist on the
broker, and a queue must be bound by the consuming service. Publishes use the
AMQP `mandatory` flag, so a publish to an exchange with no matching binding
fails with `PublishReturnedError` and the caller is expected to surface the
failure to the user.

#### `auth` exchange

Routing key: `user.deletion.requested`. Published from
`POST /settings/instance/deletion/force` when a user requests permanent
deletion of their account. The payload is the `UserDeletionRequestedMessage`
struct in `pkg/rabbitmq/contracts.go`.

#### `migration` exchange

Routing key: `nextcloud.migration.requested`. Published from
`POST /remote/nextcloud/migration` when a user starts a Nextcloud-to-Cozy
bulk migration. The payload is the `NextcloudMigrationRequestedMessage`
struct in `pkg/rabbitmq/contracts.go`:

```json
{
"migrationId": "d4e5f6a7b8c94d0ea1b2c3d4e5f6a7b8",
"workplaceFqdn": "alice.cozy.example.com",
"accountId": "a1b2c3d4e5f6",
"sourcePath": "/",
"timestamp": 1712563200
}
```

Credentials are never in the payload: they live in the `io.cozy.accounts`
document referenced by `accountId`. The Stack populates `MessageID` with the
migration ID for cross-system tracing. The consuming service is responsible
for declaring its queue, binding it to this exchange, and processing the
messages; if no queue is bound when the Stack publishes, the user receives
`503` and the tracking document is marked `failed`.

### Handlers

Handlers implement a simple interface:
Expand Down
151 changes: 151 additions & 0 deletions model/nextcloud/migration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package nextcloud

import (
"errors"
"time"

"github.com/cozy/cozy-stack/model/instance"
"github.com/cozy/cozy-stack/pkg/consts"
"github.com/cozy/cozy-stack/pkg/couchdb"
"github.com/cozy/cozy-stack/pkg/couchdb/mango"
"github.com/cozy/cozy-stack/pkg/jsonapi"
)

const (
MigrationStatusPending = "pending"
MigrationStatusRunning = "running"
MigrationStatusCompleted = "completed"
MigrationStatusFailed = "failed"
)

const DefaultMigrationTargetDir = "/Nextcloud"

var ErrMigrationConflict = errors.New("a nextcloud migration is already in progress")

// Migration is the io.cozy.nextcloud.migrations tracking document.
//
// The schema (especially the nested Progress object) is the contract with
// twake-migration-nextcloud. Flat counters would crash the service's progress
// reducer because it spreads doc.progress and adds to its fields.
type Migration struct {
DocID string `json:"_id,omitempty"`
DocRev string `json:"_rev,omitempty"`
Status string `json:"status"`
TargetDir string `json:"target_dir"`
Progress MigrationProgress `json:"progress"`
Errors []MigrationError `json:"errors"`
Skipped []SkippedFile `json:"skipped"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}

type MigrationProgress struct {
FilesImported int64 `json:"files_imported"`
FilesTotal int64 `json:"files_total"`
BytesImported int64 `json:"bytes_imported"`
BytesTotal int64 `json:"bytes_total"`
}

type MigrationError struct {
Path string `json:"path"`
Message string `json:"message"`
At time.Time `json:"at"`
}

type SkippedFile struct {
Path string `json:"path"`
Reason string `json:"reason"`
Size int64 `json:"size"`
}

func (m *Migration) ID() string { return m.DocID }
func (m *Migration) Rev() string { return m.DocRev }
func (m *Migration) DocType() string { return consts.NextcloudMigrations }
func (m *Migration) SetID(id string) { m.DocID = id }
func (m *Migration) SetRev(rev string) { m.DocRev = rev }

func (m *Migration) Clone() couchdb.Doc {
cloned := *m

if m.Errors != nil {
cloned.Errors = make([]MigrationError, len(m.Errors))
copy(cloned.Errors, m.Errors)
}
if m.Skipped != nil {
cloned.Skipped = make([]SkippedFile, len(m.Skipped))
copy(cloned.Skipped, m.Skipped)
}
if m.StartedAt != nil {
t := *m.StartedAt
cloned.StartedAt = &t
}
if m.FinishedAt != nil {
t := *m.FinishedAt
cloned.FinishedAt = &t
}
return &cloned
}

func (m *Migration) Links() *jsonapi.LinksList { return nil }
func (m *Migration) Relationships() jsonapi.RelationshipMap { return nil }
func (m *Migration) Included() []jsonapi.Object { return nil }

var (
_ couchdb.Doc = (*Migration)(nil)
_ jsonapi.Object = (*Migration)(nil)
)

// NewPendingMigration returns a fresh Migration document in the pending state.
// Errors and Skipped are explicit empty slices so the JSON serialization
// produces "[]" rather than "null" — the migration service consumes them as
// arrays and would crash on null.
func NewPendingMigration(targetDir string) *Migration {
if targetDir == "" {
targetDir = DefaultMigrationTargetDir
}
return &Migration{
Status: MigrationStatusPending,
TargetDir: targetDir,
Errors: []MigrationError{},
Skipped: []SkippedFile{},
}
}

func (m *Migration) MarkFailed(inst *instance.Instance, cause error) error {
now := time.Now().UTC()
m.Status = MigrationStatusFailed
if m.FinishedAt == nil {
m.FinishedAt = &now
}
m.Errors = append(m.Errors, MigrationError{
Message: cause.Error(),
At: now,
})
return couchdb.UpdateDoc(inst, m)
}

// FindActiveMigration returns the first pending or running migration, or
// (nil, nil) if none. A missing doctype database or index is treated as "no
// active migration" so the first call on a fresh instance succeeds.
func FindActiveMigration(inst *instance.Instance) (*Migration, error) {
var docs []*Migration
req := &couchdb.FindRequest{
UseIndex: "by-status",
Selector: mango.In("status", []interface{}{
MigrationStatusPending,
MigrationStatusRunning,
}),
Limit: 1,
}
err := couchdb.FindDocs(inst, consts.NextcloudMigrations, req, &docs)
if err != nil {
if couchdb.IsNoDatabaseError(err) || couchdb.IsNoUsableIndexError(err) {
return nil, nil
}
return nil, err
}
if len(docs) == 0 {
return nil, nil
}
return docs[0], nil
}
62 changes: 49 additions & 13 deletions model/nextcloud/nextcloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
package nextcloud

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
Expand All @@ -20,6 +22,7 @@ import (
"github.com/cozy/cozy-stack/pkg/consts"
"github.com/cozy/cozy-stack/pkg/couchdb"
"github.com/cozy/cozy-stack/pkg/jsonapi"
"github.com/cozy/cozy-stack/pkg/logger"
"github.com/cozy/cozy-stack/pkg/safehttp"
"github.com/cozy/cozy-stack/pkg/webdav"
"github.com/labstack/echo/v4"
Expand Down Expand Up @@ -360,16 +363,41 @@ func (nc *NextCloud) buildTrashedURL(item webdav.Item, path string) string {
return u.String()
}

// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-status-api.html#fetch-your-own-status
func (nc *NextCloud) fetchUserID() (string, error) {
logger := nc.webdav.Logger
u := url.URL{
Scheme: nc.webdav.Scheme,
Host: nc.webdav.Host,
User: url.UserPassword(nc.webdav.Username, nc.webdav.Password),
Path: "/ocs/v2.php/apps/user_status/api/v1/user_status",
// FetchUserIDWithCredentials probes the OCS cloud/user endpoint and returns
// the user ID, or webdav.ErrInvalidAuth if the credentials are rejected.
//
// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html
func FetchUserIDWithCredentials(nextcloudURL, username, password string, logger *logger.Entry) (string, error) {
u, err := url.Parse(nextcloudURL)
if err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if u.Scheme == "" || u.Host == "" {
return "", ErrInvalidAccount
}
return fetchUserIDFromHost(u.Scheme, u.Host, username, password, logger)
}

const probeTimeout = 30 * time.Second

// cloudUserProbePath is OCS Core and cannot be disabled by an admin, unlike
// apps/user_status which some managed Nextcloud providers strip. Probing Core
// avoids misclassifying a stripped-optional-app install as an auth failure.
const cloudUserProbePath = "/ocs/v2.php/cloud/user"

func fetchUserIDFromHost(scheme, host, username, password string, logger *logger.Entry) (string, error) {
u := url.URL{
Scheme: scheme,
Host: host,
User: url.UserPassword(username, password),
Path: cloudUserProbePath,
}
// Cap the probe so a hung Nextcloud server can't pin a request goroutine —
// safehttp.ClientWithKeepAlive has handshake timeouts but no overall
// request deadline.
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
Expand All @@ -380,13 +408,17 @@ func (nc *NextCloud) fetchUserID() (string, error) {
res, err := safehttp.ClientWithKeepAlive.Do(req)
elapsed := time.Since(start)
if err != nil {
logger.Warnf("user_status %s: %s (%s)", u.Host, err, elapsed)
logger.Warnf("cloud/user %s: %s (%s)", u.Host, err, elapsed)
return "", err
}
defer res.Body.Close()
logger.Infof("user_status %s: %d (%s)", u.Host, res.StatusCode, elapsed)
if res.StatusCode != 200 {
logger.Infof("cloud/user %s: %d (%s)", u.Host, res.StatusCode, elapsed)
switch res.StatusCode {
case http.StatusOK:
case http.StatusUnauthorized, http.StatusForbidden:
return "", webdav.ErrInvalidAuth
default:
return "", fmt.Errorf("unexpected status %d from nextcloud cloud/user probe", res.StatusCode)
}
var payload OCSPayload
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
Expand All @@ -396,10 +428,14 @@ func (nc *NextCloud) fetchUserID() (string, error) {
return payload.OCS.Data.UserID, nil
}

func (nc *NextCloud) fetchUserID() (string, error) {
return fetchUserIDFromHost(nc.webdav.Scheme, nc.webdav.Host, nc.webdav.Username, nc.webdav.Password, nc.webdav.Logger)
}

type OCSPayload struct {
OCS struct {
Data struct {
UserID string `json:"userId"`
UserID string `json:"id"`
} `json:"data"`
} `json:"ocs"`
}
Loading
Loading